jwadow commited on
Commit
936f798
·
1 Parent(s): 80b100d

feat(openai): support Cursor flat format, inverted model names, and improve tool_results handling (#49)

Browse files
CONTRIBUTORS.md CHANGED
@@ -9,5 +9,5 @@ Thank you to all the contributors who have helped improve this project!
9
  - [@JoeGrimes123](https://github.com/JoeGrimes123) — Suggesting the fake reasoning approach (#11)
10
  - [@kilhyeonjun](https://github.com/kilhyeonjun) — SQLite credentials reload for containers (#22), thinking tags fix for toolResults (#23)
11
  - [@cniu6](https://github.com/cniu6) — Image content block support inspiration (#26)
12
- - [@somehow-paul](https://github.com/somehow-paul) — Enterprise Kiro IDE support (#45, #48)
13
  - [@bhaskoro-muthohar](https://github.com/bhaskoro-muthohar) — Root cause analysis and solution for MCP tool results bug (#46, #50)
 
9
  - [@JoeGrimes123](https://github.com/JoeGrimes123) — Suggesting the fake reasoning approach (#11)
10
  - [@kilhyeonjun](https://github.com/kilhyeonjun) — SQLite credentials reload for containers (#22), thinking tags fix for toolResults (#23)
11
  - [@cniu6](https://github.com/cniu6) — Image content block support inspiration (#26)
12
+ - [@somehow-paul](https://github.com/somehow-paul) — Enterprise Kiro IDE support (#45, #48), Cursor IDE compatibility design (#49)
13
  - [@bhaskoro-muthohar](https://github.com/bhaskoro-muthohar) — Root cause analysis and solution for MCP tool results bug (#46, #50)
kiro/converters_core.py CHANGED
@@ -886,7 +886,7 @@ def strip_all_tool_content(messages: List[UnifiedMessage]) -> Tuple[List[Unified
886
 
887
  had_tool_content = total_tool_calls_stripped > 0 or total_tool_results_stripped > 0
888
 
889
- # Log summary once (DEBUG level - this is normal for clients like Cline/Roo)
890
  if had_tool_content:
891
  logger.debug(
892
  f"Converted tool content to text (no tools defined): "
@@ -901,26 +901,27 @@ def ensure_assistant_before_tool_results(messages: List[UnifiedMessage]) -> Tupl
901
  Ensures that messages with tool_results have a preceding assistant message with tool_calls.
902
 
903
  Kiro API requires that when toolResults are present, there must be a preceding
904
- assistantResponseMessage with toolUses. Some clients (like Cline/Roo) may send
905
  truncated conversations where the assistant message is missing.
906
 
907
  Since we don't know the original tool name and arguments when the assistant message
908
- is missing, we cannot create a valid synthetic assistant message. Instead, we strip
909
- the tool_results from such messages to avoid Kiro API rejection.
 
910
 
911
  Args:
912
  messages: List of messages in unified format
913
 
914
  Returns:
915
  Tuple of:
916
- - List of messages with orphaned tool_results stripped
917
- - Boolean indicating whether any tool_results were stripped (used to skip thinking tag injection)
918
  """
919
  if not messages:
920
  return [], False
921
 
922
  result = []
923
- stripped_any_tool_results = False
924
 
925
  for msg in messages:
926
  # Check if this message has tool_results
@@ -935,27 +936,40 @@ def ensure_assistant_before_tool_results(messages: List[UnifiedMessage]) -> Tupl
935
  if not has_preceding_assistant:
936
  # We cannot create a valid synthetic assistant message because we don't know
937
  # the original tool name and arguments. Kiro API validates tool names.
938
- # Strip the tool_results to avoid "Improperly formed request" error.
939
- logger.warning(
940
- f"Stripping {len(msg.tool_results)} orphaned tool_results "
941
  f"(no preceding assistant message with tool_calls). "
942
  f"Tool IDs: {[tr.get('tool_use_id', 'unknown') for tr in msg.tool_results]}"
943
  )
944
 
945
- # Create a copy of the message without tool_results
 
 
 
 
 
 
 
 
 
 
 
 
946
  cleaned_msg = UnifiedMessage(
947
  role=msg.role,
948
- content=msg.content,
949
  tool_calls=msg.tool_calls,
950
- tool_results=None # Strip orphaned tool_results
 
951
  )
952
  result.append(cleaned_msg)
953
- stripped_any_tool_results = True
954
  continue
955
 
956
  result.append(msg)
957
 
958
- return result, stripped_any_tool_results
959
 
960
 
961
  def merge_adjacent_messages(messages: List[UnifiedMessage]) -> List[UnifiedMessage]:
@@ -1179,11 +1193,11 @@ def build_kiro_payload(
1179
  if not tools:
1180
  messages_without_tools, had_tool_content = strip_all_tool_content(messages)
1181
  messages_with_assistants = messages_without_tools
1182
- stripped_tool_results = had_tool_content
1183
  else:
1184
  # Ensure assistant messages exist before tool_results (Kiro API requirement)
1185
- # Also returns flag if any tool_results were stripped (to skip thinking tag injection)
1186
- messages_with_assistants, stripped_tool_results = ensure_assistant_before_tool_results(messages)
1187
 
1188
  # Merge adjacent messages with the same role
1189
  merged_messages = merge_adjacent_messages(messages_with_assistants)
 
886
 
887
  had_tool_content = total_tool_calls_stripped > 0 or total_tool_results_stripped > 0
888
 
889
+ # Log summary once (DEBUG level - this is normal for clients like Cline/Roo/Cursor)
890
  if had_tool_content:
891
  logger.debug(
892
  f"Converted tool content to text (no tools defined): "
 
901
  Ensures that messages with tool_results have a preceding assistant message with tool_calls.
902
 
903
  Kiro API requires that when toolResults are present, there must be a preceding
904
+ assistantResponseMessage with toolUses. Some clients (like Cline/Roo/Cursor) may send
905
  truncated conversations where the assistant message is missing.
906
 
907
  Since we don't know the original tool name and arguments when the assistant message
908
+ is missing, we cannot create a valid synthetic assistant message. Instead, we convert
909
+ the tool_results to text representation and append to the message content, preserving
910
+ the context for the model while avoiding Kiro API rejection.
911
 
912
  Args:
913
  messages: List of messages in unified format
914
 
915
  Returns:
916
  Tuple of:
917
+ - List of messages with orphaned tool_results converted to text
918
+ - Boolean indicating whether any tool_results were converted (used to skip thinking tag injection)
919
  """
920
  if not messages:
921
  return [], False
922
 
923
  result = []
924
+ converted_any_tool_results = False
925
 
926
  for msg in messages:
927
  # Check if this message has tool_results
 
936
  if not has_preceding_assistant:
937
  # We cannot create a valid synthetic assistant message because we don't know
938
  # the original tool name and arguments. Kiro API validates tool names.
939
+ # Convert tool_results to text to preserve context for the model.
940
+ logger.debug(
941
+ f"Converting {len(msg.tool_results)} orphaned tool_results to text "
942
  f"(no preceding assistant message with tool_calls). "
943
  f"Tool IDs: {[tr.get('tool_use_id', 'unknown') for tr in msg.tool_results]}"
944
  )
945
 
946
+ # Convert tool_results to text representation
947
+ tool_results_text = tool_results_to_text(msg.tool_results)
948
+
949
+ # Append to existing content
950
+ original_content = extract_text_content(msg.content) or ""
951
+ if original_content and tool_results_text:
952
+ new_content = f"{original_content}\n\n{tool_results_text}"
953
+ elif tool_results_text:
954
+ new_content = tool_results_text
955
+ else:
956
+ new_content = original_content
957
+
958
+ # Create a copy of the message with tool_results converted to text
959
  cleaned_msg = UnifiedMessage(
960
  role=msg.role,
961
+ content=new_content,
962
  tool_calls=msg.tool_calls,
963
+ tool_results=None, # Remove orphaned tool_results (now in text)
964
+ images=msg.images
965
  )
966
  result.append(cleaned_msg)
967
+ converted_any_tool_results = True
968
  continue
969
 
970
  result.append(msg)
971
 
972
+ return result, converted_any_tool_results
973
 
974
 
975
  def merge_adjacent_messages(messages: List[UnifiedMessage]) -> List[UnifiedMessage]:
 
1193
  if not tools:
1194
  messages_without_tools, had_tool_content = strip_all_tool_content(messages)
1195
  messages_with_assistants = messages_without_tools
1196
+ converted_tool_results = had_tool_content
1197
  else:
1198
  # Ensure assistant messages exist before tool_results (Kiro API requirement)
1199
+ # Also returns flag if any tool_results were converted (to skip thinking tag injection)
1200
+ messages_with_assistants, converted_tool_results = ensure_assistant_before_tool_results(messages)
1201
 
1202
  # Merge adjacent messages with the same role
1203
  merged_messages = merge_adjacent_messages(messages_with_assistants)
kiro/converters_openai.py CHANGED
@@ -207,6 +207,10 @@ def convert_openai_tools_to_unified(tools: Optional[List[Tool]]) -> Optional[Lis
207
  """
208
  Converts OpenAI tools to unified format.
209
 
 
 
 
 
210
  Args:
211
  tools: List of OpenAI Tool objects
212
 
@@ -221,11 +225,24 @@ def convert_openai_tools_to_unified(tools: Optional[List[Tool]]) -> Optional[Lis
221
  if tool.type != "function":
222
  continue
223
 
224
- unified_tools.append(UnifiedTool(
225
- name=tool.function.name,
226
- description=tool.function.description,
227
- input_schema=tool.function.parameters
228
- ))
 
 
 
 
 
 
 
 
 
 
 
 
 
229
 
230
  return unified_tools if unified_tools else None
231
 
 
207
  """
208
  Converts OpenAI tools to unified format.
209
 
210
+ Supports two formats:
211
+ 1. Standard OpenAI format: {"type": "function", "function": {"name": "...", ...}}
212
+ 2. Flat format (Cursor-style): {"name": "...", "description": "...", "input_schema": {...}}
213
+
214
  Args:
215
  tools: List of OpenAI Tool objects
216
 
 
225
  if tool.type != "function":
226
  continue
227
 
228
+ # Standard OpenAI format (function field) takes priority
229
+ if tool.function is not None:
230
+ unified_tools.append(UnifiedTool(
231
+ name=tool.function.name,
232
+ description=tool.function.description,
233
+ input_schema=tool.function.parameters
234
+ ))
235
+ # Flat format compatibility (Cursor-style)
236
+ elif tool.name is not None:
237
+ unified_tools.append(UnifiedTool(
238
+ name=tool.name,
239
+ description=tool.description,
240
+ input_schema=tool.input_schema
241
+ ))
242
+ # Skip invalid tools
243
+ else:
244
+ logger.warning(f"Skipping invalid tool: no function or name field found")
245
+ continue
246
 
247
  return unified_tools if unified_tools else None
248
 
kiro/model_resolver.py CHANGED
@@ -71,6 +71,7 @@ def normalize_model_name(name: str) -> str:
71
  4. claude-sonnet-4-20250514 → claude-sonnet-4 (strip date, no minor)
72
  5. claude-3-7-sonnet → claude-3.7-sonnet (legacy format normalization)
73
  6. claude-3-7-sonnet-20250219 → claude-3.7-sonnet (legacy + strip date)
 
74
 
75
  Args:
76
  name: External model name from client
@@ -93,6 +94,10 @@ def normalize_model_name(name: str) -> str:
93
  'claude-3.7-sonnet'
94
  >>> normalize_model_name("claude-3-7-sonnet-20250219")
95
  'claude-3.7-sonnet'
 
 
 
 
96
  >>> normalize_model_name("auto")
97
  'auto'
98
  """
@@ -140,6 +145,19 @@ def normalize_model_name(name: str) -> str:
140
  if match:
141
  return match.group(1)
142
 
 
 
 
 
 
 
 
 
 
 
 
 
 
143
  # No transformation needed - return as-is (preserving original case for passthrough)
144
  return name
145
 
 
71
  4. claude-sonnet-4-20250514 → claude-sonnet-4 (strip date, no minor)
72
  5. claude-3-7-sonnet → claude-3.7-sonnet (legacy format normalization)
73
  6. claude-3-7-sonnet-20250219 → claude-3.7-sonnet (legacy + strip date)
74
+ 7. claude-4.5-opus-high → claude-opus-4.5 (inverted format with suffix)
75
 
76
  Args:
77
  name: External model name from client
 
94
  'claude-3.7-sonnet'
95
  >>> normalize_model_name("claude-3-7-sonnet-20250219")
96
  'claude-3.7-sonnet'
97
+ >>> normalize_model_name("claude-4.5-opus-high")
98
+ 'claude-opus-4.5'
99
+ >>> normalize_model_name("claude-4.5-sonnet-low")
100
+ 'claude-sonnet-4.5'
101
  >>> normalize_model_name("auto")
102
  'auto'
103
  """
 
145
  if match:
146
  return match.group(1)
147
 
148
+ # Pattern 5: Inverted format with suffix - claude-{major}.{minor}-{family}-{suffix}
149
+ # Matches: claude-4.5-opus-high, claude-4.5-sonnet-low, claude-4.5-opus-high-thinking
150
+ # Convert to: claude-{family}-{major}.{minor}
151
+ # Groups: (4), (5), (opus), any suffix
152
+ # NOTE: This pattern REQUIRES a suffix to avoid matching already-normalized formats like claude-3.7-sonnet
153
+ inverted_with_suffix_pattern = r'^claude-(\d+)\.(\d+)-(haiku|sonnet|opus)-(.+)$'
154
+ match = re.match(inverted_with_suffix_pattern, name_lower)
155
+ if match:
156
+ major = match.group(1) # 4
157
+ minor = match.group(2) # 5
158
+ family = match.group(3) # opus
159
+ return f"claude-{family}-{major}.{minor}" # claude-opus-4.5
160
+
161
  # No transformation needed - return as-is (preserving original case for passthrough)
162
  return name
163
 
kiro/models_openai.py CHANGED
@@ -102,12 +102,27 @@ class Tool(BaseModel):
102
  """
103
  Tool in OpenAI format.
104
 
 
 
 
 
105
  Attributes:
106
  type: Tool type (usually "function")
107
- function: Function description
 
 
 
108
  """
 
109
  type: str = "function"
110
- function: ToolFunction
 
 
 
 
 
 
 
111
 
112
 
113
  class ChatCompletionRequest(BaseModel):
 
102
  """
103
  Tool in OpenAI format.
104
 
105
+ Supports two formats:
106
+ 1. Standard OpenAI format: {"type": "function", "function": {...}}
107
+ 2. Flat format (Cursor-style): {"name": "...", "description": "...", "input_schema": {...}}
108
+
109
  Attributes:
110
  type: Tool type (usually "function")
111
+ function: Function description (standard format)
112
+ name: Function name (flat format)
113
+ description: Function description (flat format)
114
+ input_schema: Function parameters (flat format)
115
  """
116
+ # Standard OpenAI format fields
117
  type: str = "function"
118
+ function: Optional[ToolFunction] = None
119
+
120
+ # Flat format fields (Cursor-style)
121
+ name: Optional[str] = None
122
+ description: Optional[str] = None
123
+ input_schema: Optional[Dict[str, Any]] = None
124
+
125
+ model_config = {"extra": "allow"}
126
 
127
 
128
  class ChatCompletionRequest(BaseModel):
tests/unit/test_converters_core.py CHANGED
@@ -1273,7 +1273,7 @@ class TestEnsureAssistantBeforeToolResults:
1273
  """
1274
  Tests for ensure_assistant_before_tool_results function.
1275
 
1276
- This function handles the case when clients (like Cline/Roo) send truncated
1277
  conversations with tool_results but without the preceding assistant message
1278
  that contains the tool_calls. Since we don't know the original tool name,
1279
  we strip the orphaned tool_results to avoid Kiro API rejection.
@@ -1358,11 +1358,11 @@ class TestEnsureAssistantBeforeToolResults:
1358
 
1359
  def test_strips_orphaned_tool_results_at_start(self):
1360
  """
1361
- What it does: Verifies orphaned tool_results at the start are stripped.
1362
- Purpose: Ensure tool_results without preceding assistant are removed.
1363
 
1364
  This is the critical bug fix test - when a client sends a truncated
1365
- conversation starting with tool_results, they should be stripped.
1366
  """
1367
  print("Setup: Conversation starting with orphaned tool_results...")
1368
  messages = [
@@ -1379,21 +1379,26 @@ class TestEnsureAssistantBeforeToolResults:
1379
  ]
1380
 
1381
  print("Action: Processing messages...")
1382
- result, stripped = ensure_assistant_before_tool_results(messages)
1383
 
1384
  print(f"Result: {result}")
1385
  print(f"Comparing length: Expected 2, Got {len(result)}")
1386
  assert len(result) == 2
1387
 
1388
- print("Checking that orphaned tool_results are stripped...")
1389
  assert result[0].tool_results is None
1390
- assert result[0].content == "" # Content preserved
 
 
 
 
 
1391
  assert result[1].content == "Continue the conversation"
1392
- assert stripped is True
1393
 
1394
- def test_strips_tool_results_after_assistant_without_tool_calls(self):
1395
  """
1396
- What it does: Verifies tool_results are stripped when preceding assistant has no tool_calls.
1397
  Purpose: Ensure tool_results require assistant with tool_calls, not just any assistant.
1398
  """
1399
  print("Setup: Assistant without tool_calls followed by user with tool_results...")
@@ -1412,16 +1417,21 @@ class TestEnsureAssistantBeforeToolResults:
1412
  ]
1413
 
1414
  print("Action: Processing messages...")
1415
- result, stripped = ensure_assistant_before_tool_results(messages)
1416
 
1417
  print(f"Result: {result}")
1418
- print("Checking that tool_results are stripped...")
1419
  assert result[2].tool_results is None
1420
- assert stripped is True
 
 
 
 
 
1421
 
1422
- def test_strips_tool_results_after_user_message(self):
1423
  """
1424
- What it does: Verifies tool_results are stripped when preceded by user message.
1425
  Purpose: Ensure tool_results require assistant, not user.
1426
  """
1427
  print("Setup: User message followed by user with tool_results...")
@@ -1439,17 +1449,22 @@ class TestEnsureAssistantBeforeToolResults:
1439
  ]
1440
 
1441
  print("Action: Processing messages...")
1442
- result, stripped = ensure_assistant_before_tool_results(messages)
1443
 
1444
  print(f"Result: {result}")
1445
- print("Checking that tool_results are stripped...")
1446
  assert result[1].tool_results is None
1447
- assert stripped is True
 
 
 
 
 
1448
 
1449
- def test_preserves_content_when_stripping_tool_results(self):
1450
  """
1451
- What it does: Verifies message content is preserved when tool_results are stripped.
1452
- Purpose: Ensure only tool_results are removed, not the entire message.
1453
  """
1454
  print("Setup: Message with both content and orphaned tool_results...")
1455
  messages = [
@@ -1465,18 +1480,27 @@ class TestEnsureAssistantBeforeToolResults:
1465
  ]
1466
 
1467
  print("Action: Processing messages...")
1468
- result, stripped = ensure_assistant_before_tool_results(messages)
1469
 
1470
  print(f"Result: {result}")
1471
- print("Checking that content is preserved...")
1472
- assert result[0].content == "Here is some context"
 
 
 
 
 
 
 
 
1473
  assert result[0].tool_results is None
1474
- assert stripped is True
 
1475
 
1476
- def test_preserves_tool_calls_when_stripping_tool_results(self):
1477
  """
1478
- What it does: Verifies tool_calls are preserved when tool_results are stripped.
1479
- Purpose: Ensure only tool_results are removed, tool_calls stay.
1480
  """
1481
  print("Setup: Message with tool_calls and orphaned tool_results...")
1482
  messages = [
@@ -1497,19 +1521,24 @@ class TestEnsureAssistantBeforeToolResults:
1497
  ]
1498
 
1499
  print("Action: Processing messages...")
1500
- result, stripped = ensure_assistant_before_tool_results(messages)
1501
 
1502
  print(f"Result: {result}")
1503
  print("Checking that tool_calls are preserved...")
1504
  assert result[0].tool_calls is not None
1505
  assert len(result[0].tool_calls) == 1
 
 
1506
  assert result[0].tool_results is None
1507
- assert stripped is True
 
 
 
1508
 
1509
  def test_handles_multiple_orphaned_tool_results(self):
1510
  """
1511
- What it does: Verifies multiple orphaned tool_results are all stripped.
1512
- Purpose: Ensure all tool_results in the list are removed.
1513
  """
1514
  print("Setup: Message with multiple orphaned tool_results...")
1515
  messages = [
@@ -1525,12 +1554,215 @@ class TestEnsureAssistantBeforeToolResults:
1525
  ]
1526
 
1527
  print("Action: Processing messages...")
1528
- result, stripped = ensure_assistant_before_tool_results(messages)
1529
 
1530
  print(f"Result: {result}")
1531
- print("Checking that all tool_results are stripped...")
 
 
1532
  assert result[0].tool_results is None
1533
- assert stripped is True
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1534
 
1535
  def test_mixed_valid_and_orphaned_tool_results(self):
1536
  """
@@ -3261,7 +3493,7 @@ class TestStripAllToolContent:
3261
  from messages. It is used when no tools are defined in the request, because
3262
  Kiro API rejects requests that have toolResults but no tools defined.
3263
 
3264
- This is a critical function for handling clients like Cline/Roo that may
3265
  send tool-related content even when tools are not available.
3266
  """
3267
 
 
1273
  """
1274
  Tests for ensure_assistant_before_tool_results function.
1275
 
1276
+ This function handles the case when clients (like Cline/Roo/Cursor) send truncated
1277
  conversations with tool_results but without the preceding assistant message
1278
  that contains the tool_calls. Since we don't know the original tool name,
1279
  we strip the orphaned tool_results to avoid Kiro API rejection.
 
1358
 
1359
  def test_strips_orphaned_tool_results_at_start(self):
1360
  """
1361
+ What it does: Verifies orphaned tool_results at the start are converted to text.
1362
+ Purpose: Ensure tool_results without preceding assistant are converted to text representation.
1363
 
1364
  This is the critical bug fix test - when a client sends a truncated
1365
+ conversation starting with tool_results, they should be converted to text.
1366
  """
1367
  print("Setup: Conversation starting with orphaned tool_results...")
1368
  messages = [
 
1379
  ]
1380
 
1381
  print("Action: Processing messages...")
1382
+ result, converted = ensure_assistant_before_tool_results(messages)
1383
 
1384
  print(f"Result: {result}")
1385
  print(f"Comparing length: Expected 2, Got {len(result)}")
1386
  assert len(result) == 2
1387
 
1388
+ print("Checking that orphaned tool_results are converted to text...")
1389
  assert result[0].tool_results is None
1390
+
1391
+ print("Checking that content now contains the tool result as text...")
1392
+ print(f"Content: '{result[0].content}'")
1393
+ assert "[Tool Result (call_orphan)]" in result[0].content
1394
+ assert "Orphaned result" in result[0].content
1395
+
1396
  assert result[1].content == "Continue the conversation"
1397
+ assert converted is True
1398
 
1399
+ def test_converts_tool_results_after_assistant_without_tool_calls(self):
1400
  """
1401
+ What it does: Verifies tool_results are converted when preceding assistant has no tool_calls.
1402
  Purpose: Ensure tool_results require assistant with tool_calls, not just any assistant.
1403
  """
1404
  print("Setup: Assistant without tool_calls followed by user with tool_results...")
 
1417
  ]
1418
 
1419
  print("Action: Processing messages...")
1420
+ result, converted = ensure_assistant_before_tool_results(messages)
1421
 
1422
  print(f"Result: {result}")
1423
+ print("Checking that tool_results are converted to text...")
1424
  assert result[2].tool_results is None
1425
+
1426
+ print(f"Content after conversion: '{result[2].content}'")
1427
+ assert "[Tool Result (call_123)]" in result[2].content
1428
+ assert "Result" in result[2].content
1429
+
1430
+ assert converted is True
1431
 
1432
+ def test_converts_tool_results_after_user_message(self):
1433
  """
1434
+ What it does: Verifies tool_results are converted when preceded by user message.
1435
  Purpose: Ensure tool_results require assistant, not user.
1436
  """
1437
  print("Setup: User message followed by user with tool_results...")
 
1449
  ]
1450
 
1451
  print("Action: Processing messages...")
1452
+ result, converted = ensure_assistant_before_tool_results(messages)
1453
 
1454
  print(f"Result: {result}")
1455
+ print("Checking that tool_results are converted to text...")
1456
  assert result[1].tool_results is None
1457
+
1458
+ print(f"Content after conversion: '{result[1].content}'")
1459
+ assert "[Tool Result (call_123)]" in result[1].content
1460
+ assert "Result" in result[1].content
1461
+
1462
+ assert converted is True
1463
 
1464
+ def test_preserves_content_when_converting_tool_results(self):
1465
  """
1466
+ What it does: Verifies message content is preserved and tool_results are appended as text.
1467
+ Purpose: Ensure original content is kept and tool_results are converted to text representation.
1468
  """
1469
  print("Setup: Message with both content and orphaned tool_results...")
1470
  messages = [
 
1480
  ]
1481
 
1482
  print("Action: Processing messages...")
1483
+ result, converted = ensure_assistant_before_tool_results(messages)
1484
 
1485
  print(f"Result: {result}")
1486
+ print(f"Content after conversion: '{result[0].content}'")
1487
+
1488
+ print("Checking that original content is preserved...")
1489
+ assert "Here is some context" in result[0].content
1490
+
1491
+ print("Checking that tool_results are converted to text and appended...")
1492
+ assert "[Tool Result (call_123)]" in result[0].content
1493
+ assert "Result" in result[0].content
1494
+
1495
+ print("Checking that tool_results field is removed...")
1496
  assert result[0].tool_results is None
1497
+
1498
+ assert converted is True
1499
 
1500
+ def test_preserves_tool_calls_when_converting_tool_results(self):
1501
  """
1502
+ What it does: Verifies tool_calls are preserved when tool_results are converted.
1503
+ Purpose: Ensure only tool_results are converted, tool_calls stay.
1504
  """
1505
  print("Setup: Message with tool_calls and orphaned tool_results...")
1506
  messages = [
 
1521
  ]
1522
 
1523
  print("Action: Processing messages...")
1524
+ result, converted = ensure_assistant_before_tool_results(messages)
1525
 
1526
  print(f"Result: {result}")
1527
  print("Checking that tool_calls are preserved...")
1528
  assert result[0].tool_calls is not None
1529
  assert len(result[0].tool_calls) == 1
1530
+
1531
+ print("Checking that tool_results are converted to text...")
1532
  assert result[0].tool_results is None
1533
+ assert "[Tool Result (call_old)]" in result[0].content
1534
+ assert "Old result" in result[0].content
1535
+
1536
+ assert converted is True
1537
 
1538
  def test_handles_multiple_orphaned_tool_results(self):
1539
  """
1540
+ What it does: Verifies multiple orphaned tool_results are all converted.
1541
+ Purpose: Ensure all tool_results in the list are converted to text.
1542
  """
1543
  print("Setup: Message with multiple orphaned tool_results...")
1544
  messages = [
 
1554
  ]
1555
 
1556
  print("Action: Processing messages...")
1557
+ result, converted = ensure_assistant_before_tool_results(messages)
1558
 
1559
  print(f"Result: {result}")
1560
+ print(f"Content after conversion: '{result[0].content}'")
1561
+
1562
+ print("Checking that all tool_results are converted to text...")
1563
  assert result[0].tool_results is None
1564
+ assert "[Tool Result (call_1)]" in result[0].content
1565
+ assert "Result 1" in result[0].content
1566
+ assert "[Tool Result (call_2)]" in result[0].content
1567
+ assert "Result 2" in result[0].content
1568
+ assert "[Tool Result (call_3)]" in result[0].content
1569
+ assert "Result 3" in result[0].content
1570
+
1571
+ assert converted is True
1572
+
1573
+ # ==================================================================================
1574
+ # New tests for tool_results conversion (PR #49)
1575
+ # ==================================================================================
1576
+
1577
+ def test_conversion_preserves_images(self):
1578
+ """
1579
+ What it does: Verifies that images field is preserved when converting tool_results.
1580
+ Purpose: Ensure images=msg.images is set correctly in converted message.
1581
+ """
1582
+ print("Setup: Message with images and orphaned tool_results...")
1583
+ messages = [
1584
+ UnifiedMessage(
1585
+ role="user",
1586
+ content="Here's an image and tool result",
1587
+ images=[{"media_type": "image/jpeg", "data": "image_data"}],
1588
+ tool_results=[{
1589
+ "type": "tool_result",
1590
+ "tool_use_id": "call_123",
1591
+ "content": "Tool output"
1592
+ }]
1593
+ )
1594
+ ]
1595
+
1596
+ print("Action: Processing messages...")
1597
+ result, converted = ensure_assistant_before_tool_results(messages)
1598
+
1599
+ print(f"Result: {result}")
1600
+ print("Checking that images are preserved...")
1601
+ assert result[0].images is not None
1602
+ assert len(result[0].images) == 1
1603
+ assert result[0].images[0]["media_type"] == "image/jpeg"
1604
+
1605
+ print("Checking that tool_results are converted...")
1606
+ assert result[0].tool_results is None
1607
+ assert "[Tool Result" in result[0].content
1608
+
1609
+ assert converted is True
1610
+
1611
+ def test_conversion_appends_to_existing_content(self):
1612
+ """
1613
+ What it does: Verifies tool_results are appended with double newline.
1614
+ Purpose: Ensure formatting: "original\\n\\n[Tool Result]\\ndata".
1615
+ """
1616
+ print("Setup: Message with content and orphaned tool_results...")
1617
+ messages = [
1618
+ UnifiedMessage(
1619
+ role="user",
1620
+ content="Original content here",
1621
+ tool_results=[{
1622
+ "type": "tool_result",
1623
+ "tool_use_id": "call_abc",
1624
+ "content": "Tool data"
1625
+ }]
1626
+ )
1627
+ ]
1628
+
1629
+ print("Action: Processing messages...")
1630
+ result, converted = ensure_assistant_before_tool_results(messages)
1631
+
1632
+ print(f"Result content: '{result[0].content}'")
1633
+
1634
+ print("Checking formatting...")
1635
+ assert "Original content here" in result[0].content
1636
+ assert "[Tool Result (call_abc)]" in result[0].content
1637
+ assert "Tool data" in result[0].content
1638
+
1639
+ # Check double newline separator
1640
+ assert "\n\n" in result[0].content
1641
+
1642
+ assert converted is True
1643
+
1644
+ def test_conversion_handles_empty_original_content(self):
1645
+ """
1646
+ What it does: Verifies conversion works when original content is empty.
1647
+ Purpose: Ensure that only tool_results text is used when content is empty.
1648
+ """
1649
+ print("Setup: Message with empty content and orphaned tool_results...")
1650
+ messages = [
1651
+ UnifiedMessage(
1652
+ role="user",
1653
+ content="",
1654
+ tool_results=[{
1655
+ "type": "tool_result",
1656
+ "tool_use_id": "call_xyz",
1657
+ "content": "Only tool result"
1658
+ }]
1659
+ )
1660
+ ]
1661
+
1662
+ print("Action: Processing messages...")
1663
+ result, converted = ensure_assistant_before_tool_results(messages)
1664
+
1665
+ print(f"Result content: '{result[0].content}'")
1666
+
1667
+ print("Checking that only tool result text is present...")
1668
+ assert "[Tool Result (call_xyz)]" in result[0].content
1669
+ assert "Only tool result" in result[0].content
1670
+
1671
+ # Should not have leading/trailing whitespace from empty original content
1672
+ assert result[0].content.strip() == result[0].content
1673
+
1674
+ assert converted is True
1675
+
1676
+ def test_conversion_returns_correct_flag(self):
1677
+ """
1678
+ What it does: Verifies that converted_any_tool_results flag is returned correctly.
1679
+ Purpose: Ensure return value accurately reflects whether conversion happened.
1680
+ """
1681
+ print("Setup: Two scenarios - with and without orphaned tool_results...")
1682
+
1683
+ # Scenario 1: With orphaned tool_results (should return True)
1684
+ messages_with_orphaned = [
1685
+ UnifiedMessage(
1686
+ role="user",
1687
+ content="Test",
1688
+ tool_results=[{"type": "tool_result", "tool_use_id": "call_1", "content": "Result"}]
1689
+ )
1690
+ ]
1691
+
1692
+ print("Action: Processing messages with orphaned tool_results...")
1693
+ result1, converted1 = ensure_assistant_before_tool_results(messages_with_orphaned)
1694
+
1695
+ print(f"Comparing converted flag: Expected True, Got {converted1}")
1696
+ assert converted1 is True
1697
+
1698
+ # Scenario 2: Without orphaned tool_results (should return False)
1699
+ messages_without_orphaned = [
1700
+ UnifiedMessage(role="user", content="Hello"),
1701
+ UnifiedMessage(
1702
+ role="assistant",
1703
+ content="",
1704
+ tool_calls=[{"id": "call_1", "type": "function", "function": {"name": "tool", "arguments": "{}"}}]
1705
+ ),
1706
+ UnifiedMessage(
1707
+ role="user",
1708
+ content="",
1709
+ tool_results=[{"type": "tool_result", "tool_use_id": "call_1", "content": "Result"}]
1710
+ )
1711
+ ]
1712
+
1713
+ print("Action: Processing messages without orphaned tool_results...")
1714
+ result2, converted2 = ensure_assistant_before_tool_results(messages_without_orphaned)
1715
+
1716
+ print(f"Comparing converted flag: Expected False, Got {converted2}")
1717
+ assert converted2 is False
1718
+
1719
+ def test_normal_tool_results_unchanged(self):
1720
+ """
1721
+ What it does: Verifies that normal (non-orphaned) tool_results are NOT converted.
1722
+ Purpose: CRITICAL - ensure 99% of cases (normal tool use) have zero change.
1723
+
1724
+ This is the most important backward compatibility test. Normal tool_results
1725
+ (with preceding assistant message with tool_calls) should pass through unchanged.
1726
+ """
1727
+ print("Setup: Normal conversation with valid tool_results...")
1728
+ messages = [
1729
+ UnifiedMessage(role="user", content="Call a tool"),
1730
+ UnifiedMessage(
1731
+ role="assistant",
1732
+ content="",
1733
+ tool_calls=[{
1734
+ "id": "call_valid",
1735
+ "type": "function",
1736
+ "function": {"name": "test_tool", "arguments": "{}"}
1737
+ }]
1738
+ ),
1739
+ UnifiedMessage(
1740
+ role="user",
1741
+ content="",
1742
+ tool_results=[{
1743
+ "type": "tool_result",
1744
+ "tool_use_id": "call_valid",
1745
+ "content": "Tool executed successfully"
1746
+ }]
1747
+ )
1748
+ ]
1749
+
1750
+ print("Action: Processing messages...")
1751
+ result, converted = ensure_assistant_before_tool_results(messages)
1752
+
1753
+ print(f"Result: {result}")
1754
+ print(f"Comparing converted flag: Expected False, Got {converted}")
1755
+ assert converted is False # No conversion happened
1756
+
1757
+ print("Checking that tool_results are preserved (NOT converted)...")
1758
+ assert result[2].tool_results is not None # Still has tool_results
1759
+ assert len(result[2].tool_results) == 1
1760
+ assert result[2].tool_results[0]["tool_use_id"] == "call_valid"
1761
+ assert result[2].tool_results[0]["content"] == "Tool executed successfully"
1762
+
1763
+ print("Checking that content is NOT modified...")
1764
+ assert result[2].content == "" # Original empty content preserved
1765
+ assert "[Tool Result" not in result[2].content # NOT converted to text
1766
 
1767
  def test_mixed_valid_and_orphaned_tool_results(self):
1768
  """
 
3493
  from messages. It is used when no tools are defined in the request, because
3494
  Kiro API rejects requests that have toolResults but no tools defined.
3495
 
3496
+ This is a critical function for handling clients like Cline/Roo/Cursor that may
3497
  send tool-related content even when tools are not available.
3498
  """
3499
 
tests/unit/test_converters_openai.py CHANGED
@@ -459,6 +459,205 @@ class TestConvertOpenAIToolsToUnified:
459
  assert result[0].name == "tool1"
460
  assert result[1].name == "tool2"
461
  assert result[2].name == "tool3"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
462
 
463
 
464
  # ==================================================================================================
 
459
  assert result[0].name == "tool1"
460
  assert result[1].name == "tool2"
461
  assert result[2].name == "tool3"
462
+
463
+ # ==================================================================================
464
+ # Cursor IDE Flat Tool Format Tests (PR #49)
465
+ # ==================================================================================
466
+
467
+ def test_converts_flat_format_tool(self):
468
+ """
469
+ What it does: Verifies conversion of flat format tool (Cursor-style).
470
+ Purpose: Ensure Cursor IDE flat format is supported.
471
+
472
+ Cursor IDE sends tools in flat format:
473
+ {"type": "function", "name": "...", "description": "...", "input_schema": {...}}
474
+ instead of standard OpenAI nested format.
475
+ """
476
+ print("Setup: Flat format tool (Cursor-style)...")
477
+ tools = [Tool(
478
+ type="function",
479
+ name="cursor_tool",
480
+ description="A tool from Cursor IDE",
481
+ input_schema={"type": "object", "properties": {"param": {"type": "string"}}}
482
+ )]
483
+
484
+ print("Action: Converting tools...")
485
+ result = convert_openai_tools_to_unified(tools)
486
+
487
+ print(f"Result: {result}")
488
+ print(f"Comparing count: Expected 1, Got {len(result) if result else 0}")
489
+ assert result is not None
490
+ assert len(result) == 1
491
+
492
+ print(f"Comparing name: Expected 'cursor_tool', Got '{result[0].name}'")
493
+ assert result[0].name == "cursor_tool"
494
+
495
+ print(f"Comparing description: Expected 'A tool from Cursor IDE', Got '{result[0].description}'")
496
+ assert result[0].description == "A tool from Cursor IDE"
497
+
498
+ print(f"Comparing input_schema: Got {result[0].input_schema}")
499
+ assert result[0].input_schema == {"type": "object", "properties": {"param": {"type": "string"}}}
500
+
501
+ def test_converts_mixed_format_tools(self):
502
+ """
503
+ What it does: Verifies conversion of mixed format tools.
504
+ Purpose: Ensure both standard and flat format can coexist in same request.
505
+
506
+ This simulates a scenario where some tools are in standard OpenAI format
507
+ and some are in Cursor flat format (though unlikely in practice).
508
+ """
509
+ print("Setup: Mixed format tools...")
510
+ tools = [
511
+ # Standard OpenAI format
512
+ Tool(
513
+ type="function",
514
+ function=ToolFunction(
515
+ name="standard_tool",
516
+ description="Standard format",
517
+ parameters={"type": "object"}
518
+ )
519
+ ),
520
+ # Cursor flat format
521
+ Tool(
522
+ type="function",
523
+ name="flat_tool",
524
+ description="Flat format",
525
+ input_schema={"type": "object"}
526
+ )
527
+ ]
528
+
529
+ print("Action: Converting tools...")
530
+ result = convert_openai_tools_to_unified(tools)
531
+
532
+ print(f"Result: {result}")
533
+ print(f"Comparing count: Expected 2, Got {len(result)}")
534
+ assert len(result) == 2
535
+
536
+ print("Checking standard format tool...")
537
+ assert result[0].name == "standard_tool"
538
+ assert result[0].description == "Standard format"
539
+
540
+ print("Checking flat format tool...")
541
+ assert result[1].name == "flat_tool"
542
+ assert result[1].description == "Flat format"
543
+
544
+ def test_standard_format_takes_priority(self):
545
+ """
546
+ What it does: Verifies that standard format takes priority over flat format.
547
+ Purpose: Ensure function field is used when both formats are present (edge case).
548
+
549
+ This is an edge case where a tool has BOTH function and name fields.
550
+ The standard format (function) should take priority.
551
+ """
552
+ print("Setup: Tool with BOTH formats (edge case)...")
553
+ tools = [Tool(
554
+ type="function",
555
+ # Standard format
556
+ function=ToolFunction(
557
+ name="standard_name",
558
+ description="Standard description",
559
+ parameters={"type": "object", "properties": {"a": {"type": "string"}}}
560
+ ),
561
+ # Flat format (should be ignored)
562
+ name="flat_name",
563
+ description="Flat description",
564
+ input_schema={"type": "object", "properties": {"b": {"type": "string"}}}
565
+ )]
566
+
567
+ print("Action: Converting tools...")
568
+ result = convert_openai_tools_to_unified(tools)
569
+
570
+ print(f"Result: {result}")
571
+ assert len(result) == 1
572
+
573
+ print("Checking that standard format was used (not flat)...")
574
+ print(f"Comparing name: Expected 'standard_name', Got '{result[0].name}'")
575
+ assert result[0].name == "standard_name"
576
+
577
+ print(f"Comparing description: Expected 'Standard description', Got '{result[0].description}'")
578
+ assert result[0].description == "Standard description"
579
+
580
+ print(f"Comparing input_schema: Got {result[0].input_schema}")
581
+ assert result[0].input_schema == {"type": "object", "properties": {"a": {"type": "string"}}}
582
+
583
+ def test_skips_invalid_tools(self):
584
+ """
585
+ What it does: Verifies that tools without function OR name are skipped.
586
+ Purpose: Ensure invalid tools don't crash the conversion.
587
+
588
+ This tests the error handling when a tool has neither function nor name field.
589
+ """
590
+ print("Setup: Invalid tool (no function, no name)...")
591
+ tools = [
592
+ # Valid tool
593
+ Tool(
594
+ type="function",
595
+ function=ToolFunction(name="valid_tool", description="Valid")
596
+ ),
597
+ # Invalid tool (neither function nor name)
598
+ Tool(type="function"),
599
+ # Another valid tool
600
+ Tool(
601
+ type="function",
602
+ name="another_valid",
603
+ description="Also valid",
604
+ input_schema={}
605
+ )
606
+ ]
607
+
608
+ print("Action: Converting tools...")
609
+ result = convert_openai_tools_to_unified(tools)
610
+
611
+ print(f"Result: {result}")
612
+ print(f"Comparing count: Expected 2 (invalid skipped), Got {len(result)}")
613
+ assert len(result) == 2
614
+
615
+ print("Checking that only valid tools were converted...")
616
+ assert result[0].name == "valid_tool"
617
+ assert result[1].name == "another_valid"
618
+
619
+ def test_backward_compat_standard_openai_tools(self):
620
+ """
621
+ What it does: Verifies that standard OpenAI format is not broken.
622
+ Purpose: Regression test for existing clients (non-Cursor).
623
+
624
+ This is a critical backward compatibility test. After adding support for
625
+ Cursor's flat format, we must ensure standard OpenAI format still works.
626
+ """
627
+ print("Setup: Standard OpenAI tools (regression test)...")
628
+ tools = [
629
+ Tool(
630
+ type="function",
631
+ function=ToolFunction(
632
+ name="get_weather",
633
+ description="Get weather for a location",
634
+ parameters={
635
+ "type": "object",
636
+ "properties": {
637
+ "location": {"type": "string", "description": "City name"}
638
+ },
639
+ "required": ["location"]
640
+ }
641
+ )
642
+ )
643
+ ]
644
+
645
+ print("Action: Converting tools...")
646
+ result = convert_openai_tools_to_unified(tools)
647
+
648
+ print(f"Result: {result}")
649
+ assert result is not None
650
+ assert len(result) == 1
651
+
652
+ print(f"Comparing name: Expected 'get_weather', Got '{result[0].name}'")
653
+ assert result[0].name == "get_weather"
654
+
655
+ print(f"Comparing description: Expected 'Get weather for a location', Got '{result[0].description}'")
656
+ assert result[0].description == "Get weather for a location"
657
+
658
+ print(f"Comparing input_schema: Got {result[0].input_schema}")
659
+ assert result[0].input_schema["required"] == ["location"]
660
+ assert result[0].input_schema["properties"]["location"]["type"] == "string"
661
 
662
 
663
  # ==================================================================================================
tests/unit/test_model_resolver.py CHANGED
@@ -243,6 +243,99 @@ class TestNormalizeModelName:
243
  print(f"Comparing result: Expected 'claude-3.0-opus', Got '{result}'")
244
  assert result == "claude-3.0-opus"
245
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
246
  # === Already normalized (passthrough) ===
247
 
248
  def test_passthrough_already_normalized_haiku(self):
 
243
  print(f"Comparing result: Expected 'claude-3.0-opus', Got '{result}'")
244
  assert result == "claude-3.0-opus"
245
 
246
+ # === Inverted format with suffix (Pattern 5 - Cursor IDE) ===
247
+
248
+ def test_inverted_format_with_high_suffix(self):
249
+ """
250
+ What it does: claude-4.5-opus-high → claude-opus-4.5
251
+ Goal: Check inverted format normalization with 'high' suffix (Cursor IDE).
252
+
253
+ Cursor IDE sends model names in inverted format with priority suffix.
254
+ This is Pattern 5 from PR #49.
255
+ """
256
+ print("Action: Normalizing 'claude-4.5-opus-high'...")
257
+ result = normalize_model_name("claude-4.5-opus-high")
258
+
259
+ print(f"Comparing result: Expected 'claude-opus-4.5', Got '{result}'")
260
+ assert result == "claude-opus-4.5"
261
+
262
+ def test_inverted_format_with_low_suffix(self):
263
+ """
264
+ What it does: claude-4.5-sonnet-low → claude-sonnet-4.5
265
+ Goal: Check inverted format normalization with 'low' suffix (Cursor IDE).
266
+ """
267
+ print("Action: Normalizing 'claude-4.5-sonnet-low'...")
268
+ result = normalize_model_name("claude-4.5-sonnet-low")
269
+
270
+ print(f"Comparing result: Expected 'claude-sonnet-4.5', Got '{result}'")
271
+ assert result == "claude-sonnet-4.5"
272
+
273
+ def test_inverted_format_with_thinking_suffix(self):
274
+ """
275
+ What it does: claude-4.5-opus-high-thinking → claude-opus-4.5
276
+ Goal: Check inverted format with compound suffix (high-thinking).
277
+
278
+ The pattern strips ALL suffixes after the family name.
279
+ """
280
+ print("Action: Normalizing 'claude-4.5-opus-high-thinking'...")
281
+ result = normalize_model_name("claude-4.5-opus-high-thinking")
282
+
283
+ print(f"Comparing result: Expected 'claude-opus-4.5', Got '{result}'")
284
+ assert result == "claude-opus-4.5"
285
+
286
+ def test_inverted_format_all_families(self):
287
+ """
288
+ What it does: Verifies inverted format works for all families.
289
+ Goal: Check haiku, sonnet, opus all work with inverted format.
290
+ """
291
+ print("Action: Normalizing inverted format for all families...")
292
+
293
+ print(" Testing haiku...")
294
+ result_haiku = normalize_model_name("claude-4.5-haiku-high")
295
+ print(f" Comparing: Expected 'claude-haiku-4.5', Got '{result_haiku}'")
296
+ assert result_haiku == "claude-haiku-4.5"
297
+
298
+ print(" Testing sonnet...")
299
+ result_sonnet = normalize_model_name("claude-4.5-sonnet-low")
300
+ print(f" Comparing: Expected 'claude-sonnet-4.5', Got '{result_sonnet}'")
301
+ assert result_sonnet == "claude-sonnet-4.5"
302
+
303
+ print(" Testing opus...")
304
+ result_opus = normalize_model_name("claude-4.5-opus-high")
305
+ print(f" Comparing: Expected 'claude-opus-4.5', Got '{result_opus}'")
306
+ assert result_opus == "claude-opus-4.5"
307
+
308
+ def test_inverted_format_requires_suffix(self):
309
+ """
310
+ What it does: Verifies that suffix is required (doesn't match claude-3.7-sonnet).
311
+ Goal: CRITICAL - ensure Pattern 5 doesn't break already-normalized formats.
312
+
313
+ This is the most important test for Pattern 5. The regex MUST require a suffix
314
+ to avoid matching already-normalized formats like claude-3.7-sonnet.
315
+ """
316
+ print("Action: Normalizing 'claude-3.7-sonnet' (should NOT match Pattern 5)...")
317
+ result = normalize_model_name("claude-3.7-sonnet")
318
+
319
+ print(f"Comparing result: Expected 'claude-3.7-sonnet' (unchanged), Got '{result}'")
320
+ assert result == "claude-3.7-sonnet"
321
+
322
+ print("Action: Normalizing 'claude-4.5-sonnet' (should NOT match Pattern 5)...")
323
+ result2 = normalize_model_name("claude-4.5-sonnet")
324
+
325
+ print(f"Comparing result: Expected 'claude-4.5-sonnet' (unchanged), Got '{result2}'")
326
+ assert result2 == "claude-4.5-sonnet"
327
+
328
+ def test_inverted_format_case_insensitive(self):
329
+ """
330
+ What it does: CLAUDE-4.5-OPUS-HIGH → claude-opus-4.5
331
+ Goal: Check case insensitivity for inverted format.
332
+ """
333
+ print("Action: Normalizing 'CLAUDE-4.5-OPUS-HIGH'...")
334
+ result = normalize_model_name("CLAUDE-4.5-OPUS-HIGH")
335
+
336
+ print(f"Comparing result: Expected 'claude-opus-4.5', Got '{result}'")
337
+ assert result == "claude-opus-4.5"
338
+
339
  # === Already normalized (passthrough) ===
340
 
341
  def test_passthrough_already_normalized_haiku(self):
tests/unit/test_models_openai.py CHANGED
@@ -465,19 +465,46 @@ class TestTool:
465
  print(f"Comparing type: Expected 'function', Got '{tool.type}'")
466
  assert tool.type == "function"
467
 
468
- def test_requires_function(self):
469
  """
470
- What it does: Verifies that function is required.
471
- Purpose: Ensure validation fails without function.
472
  """
473
- print("Setup: Attempting to create Tool without function...")
 
 
 
 
 
 
474
 
475
- print("Action: Creating model (should raise ValidationError)...")
476
- with pytest.raises(ValidationError) as exc_info:
477
- Tool(type="function")
478
 
479
- print(f"ValidationError raised: {exc_info.value}")
480
- assert "function" in str(exc_info.value)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
481
 
482
 
483
  # ==================================================================================================
 
465
  print(f"Comparing type: Expected 'function', Got '{tool.type}'")
466
  assert tool.type == "function"
467
 
468
+ def test_function_is_optional_for_flat_format(self):
469
  """
470
+ What it does: Verifies that function is optional (for flat format compatibility).
471
+ Purpose: Ensure flat format (Cursor-style) is supported without function field.
472
  """
473
+ print("Setup: Creating Tool with flat format (name, description, input_schema)...")
474
+ tool = Tool(
475
+ type="function",
476
+ name="test_tool",
477
+ description="A test tool",
478
+ input_schema={"type": "object", "properties": {}}
479
+ )
480
 
481
+ print(f"Result: {tool}")
482
+ print(f"Comparing name: Expected 'test_tool', Got '{tool.name}'")
483
+ assert tool.name == "test_tool"
484
 
485
+ print(f"Comparing function: Expected None, Got {tool.function}")
486
+ assert tool.function is None
487
+
488
+ print(f"Comparing description: Expected 'A test tool', Got '{tool.description}'")
489
+ assert tool.description == "A test tool"
490
+
491
+ def test_standard_format_still_works(self):
492
+ """
493
+ What it does: Verifies that standard OpenAI format still works.
494
+ Purpose: Ensure backward compatibility with standard format.
495
+ """
496
+ print("Setup: Creating Tool with standard OpenAI format (function field)...")
497
+ tool = Tool(
498
+ type="function",
499
+ function=ToolFunction(name="standard_tool", description="Standard")
500
+ )
501
+
502
+ print(f"Result: {tool}")
503
+ print(f"Comparing function.name: Expected 'standard_tool', Got '{tool.function.name}'")
504
+ assert tool.function.name == "standard_tool"
505
+
506
+ print(f"Comparing name: Expected None, Got {tool.name}")
507
+ assert tool.name is None
508
 
509
 
510
  # ==================================================================================================