kiln-ai 0.8.1__py3-none-any.whl → 0.12.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of kiln-ai might be problematic. Click here for more details.

Files changed (88) hide show
  1. kiln_ai/adapters/__init__.py +7 -7
  2. kiln_ai/adapters/adapter_registry.py +81 -10
  3. kiln_ai/adapters/data_gen/data_gen_task.py +21 -3
  4. kiln_ai/adapters/data_gen/test_data_gen_task.py +23 -3
  5. kiln_ai/adapters/eval/base_eval.py +164 -0
  6. kiln_ai/adapters/eval/eval_runner.py +267 -0
  7. kiln_ai/adapters/eval/g_eval.py +367 -0
  8. kiln_ai/adapters/eval/registry.py +16 -0
  9. kiln_ai/adapters/eval/test_base_eval.py +324 -0
  10. kiln_ai/adapters/eval/test_eval_runner.py +640 -0
  11. kiln_ai/adapters/eval/test_g_eval.py +497 -0
  12. kiln_ai/adapters/eval/test_g_eval_data.py +4 -0
  13. kiln_ai/adapters/fine_tune/base_finetune.py +5 -1
  14. kiln_ai/adapters/fine_tune/dataset_formatter.py +310 -65
  15. kiln_ai/adapters/fine_tune/fireworks_finetune.py +47 -32
  16. kiln_ai/adapters/fine_tune/openai_finetune.py +12 -11
  17. kiln_ai/adapters/fine_tune/test_base_finetune.py +19 -0
  18. kiln_ai/adapters/fine_tune/test_dataset_formatter.py +472 -129
  19. kiln_ai/adapters/fine_tune/test_fireworks_tinetune.py +114 -22
  20. kiln_ai/adapters/fine_tune/test_openai_finetune.py +125 -14
  21. kiln_ai/adapters/ml_model_list.py +434 -93
  22. kiln_ai/adapters/model_adapters/__init__.py +18 -0
  23. kiln_ai/adapters/model_adapters/base_adapter.py +250 -0
  24. kiln_ai/adapters/model_adapters/langchain_adapters.py +309 -0
  25. kiln_ai/adapters/model_adapters/openai_compatible_config.py +10 -0
  26. kiln_ai/adapters/model_adapters/openai_model_adapter.py +289 -0
  27. kiln_ai/adapters/model_adapters/test_base_adapter.py +199 -0
  28. kiln_ai/adapters/{test_langchain_adapter.py → model_adapters/test_langchain_adapter.py} +105 -97
  29. kiln_ai/adapters/model_adapters/test_openai_model_adapter.py +216 -0
  30. kiln_ai/adapters/{test_saving_adapter_results.py → model_adapters/test_saving_adapter_results.py} +80 -30
  31. kiln_ai/adapters/{test_structured_output.py → model_adapters/test_structured_output.py} +125 -46
  32. kiln_ai/adapters/ollama_tools.py +0 -1
  33. kiln_ai/adapters/parsers/__init__.py +10 -0
  34. kiln_ai/adapters/parsers/base_parser.py +12 -0
  35. kiln_ai/adapters/parsers/json_parser.py +37 -0
  36. kiln_ai/adapters/parsers/parser_registry.py +19 -0
  37. kiln_ai/adapters/parsers/r1_parser.py +69 -0
  38. kiln_ai/adapters/parsers/test_json_parser.py +81 -0
  39. kiln_ai/adapters/parsers/test_parser_registry.py +32 -0
  40. kiln_ai/adapters/parsers/test_r1_parser.py +144 -0
  41. kiln_ai/adapters/prompt_builders.py +193 -49
  42. kiln_ai/adapters/provider_tools.py +91 -36
  43. kiln_ai/adapters/repair/repair_task.py +18 -19
  44. kiln_ai/adapters/repair/test_repair_task.py +7 -7
  45. kiln_ai/adapters/run_output.py +11 -0
  46. kiln_ai/adapters/test_adapter_registry.py +177 -0
  47. kiln_ai/adapters/test_generate_docs.py +69 -0
  48. kiln_ai/adapters/test_ollama_tools.py +0 -1
  49. kiln_ai/adapters/test_prompt_adaptors.py +25 -18
  50. kiln_ai/adapters/test_prompt_builders.py +265 -44
  51. kiln_ai/adapters/test_provider_tools.py +268 -46
  52. kiln_ai/datamodel/__init__.py +51 -772
  53. kiln_ai/datamodel/basemodel.py +31 -11
  54. kiln_ai/datamodel/datamodel_enums.py +58 -0
  55. kiln_ai/datamodel/dataset_filters.py +114 -0
  56. kiln_ai/datamodel/dataset_split.py +170 -0
  57. kiln_ai/datamodel/eval.py +298 -0
  58. kiln_ai/datamodel/finetune.py +105 -0
  59. kiln_ai/datamodel/json_schema.py +14 -3
  60. kiln_ai/datamodel/model_cache.py +8 -3
  61. kiln_ai/datamodel/project.py +23 -0
  62. kiln_ai/datamodel/prompt.py +37 -0
  63. kiln_ai/datamodel/prompt_id.py +83 -0
  64. kiln_ai/datamodel/strict_mode.py +24 -0
  65. kiln_ai/datamodel/task.py +181 -0
  66. kiln_ai/datamodel/task_output.py +321 -0
  67. kiln_ai/datamodel/task_run.py +164 -0
  68. kiln_ai/datamodel/test_basemodel.py +80 -2
  69. kiln_ai/datamodel/test_dataset_filters.py +71 -0
  70. kiln_ai/datamodel/test_dataset_split.py +127 -6
  71. kiln_ai/datamodel/test_datasource.py +3 -2
  72. kiln_ai/datamodel/test_eval_model.py +635 -0
  73. kiln_ai/datamodel/test_example_models.py +34 -17
  74. kiln_ai/datamodel/test_json_schema.py +23 -0
  75. kiln_ai/datamodel/test_model_cache.py +24 -0
  76. kiln_ai/datamodel/test_model_perf.py +125 -0
  77. kiln_ai/datamodel/test_models.py +131 -2
  78. kiln_ai/datamodel/test_prompt_id.py +129 -0
  79. kiln_ai/datamodel/test_task.py +159 -0
  80. kiln_ai/utils/config.py +6 -1
  81. kiln_ai/utils/exhaustive_error.py +6 -0
  82. {kiln_ai-0.8.1.dist-info → kiln_ai-0.12.0.dist-info}/METADATA +45 -7
  83. kiln_ai-0.12.0.dist-info/RECORD +100 -0
  84. kiln_ai/adapters/base_adapter.py +0 -191
  85. kiln_ai/adapters/langchain_adapters.py +0 -256
  86. kiln_ai-0.8.1.dist-info/RECORD +0 -58
  87. {kiln_ai-0.8.1.dist-info → kiln_ai-0.12.0.dist-info}/WHEEL +0 -0
  88. {kiln_ai-0.8.1.dist-info → kiln_ai-0.12.0.dist-info}/licenses/LICENSE.txt +0 -0
@@ -0,0 +1,289 @@
1
+ from typing import Any, Dict
2
+
3
+ from openai import AsyncOpenAI
4
+ from openai.types.chat import (
5
+ ChatCompletion,
6
+ ChatCompletionAssistantMessageParam,
7
+ ChatCompletionSystemMessageParam,
8
+ ChatCompletionUserMessageParam,
9
+ )
10
+
11
+ import kiln_ai.datamodel as datamodel
12
+ from kiln_ai.adapters.ml_model_list import (
13
+ KilnModelProvider,
14
+ ModelProviderName,
15
+ StructuredOutputMode,
16
+ )
17
+ from kiln_ai.adapters.model_adapters.base_adapter import (
18
+ COT_FINAL_ANSWER_PROMPT,
19
+ AdapterConfig,
20
+ BaseAdapter,
21
+ RunOutput,
22
+ )
23
+ from kiln_ai.adapters.model_adapters.openai_compatible_config import (
24
+ OpenAICompatibleConfig,
25
+ )
26
+ from kiln_ai.adapters.parsers.json_parser import parse_json_string
27
+ from kiln_ai.datamodel import PromptGenerators, PromptId
28
+ from kiln_ai.datamodel.task import RunConfig
29
+ from kiln_ai.utils.exhaustive_error import raise_exhaustive_enum_error
30
+
31
+
32
+ class OpenAICompatibleAdapter(BaseAdapter):
33
+ def __init__(
34
+ self,
35
+ config: OpenAICompatibleConfig,
36
+ kiln_task: datamodel.Task,
37
+ prompt_id: PromptId | None = None,
38
+ base_adapter_config: AdapterConfig | None = None,
39
+ ):
40
+ self.config = config
41
+ self.client = AsyncOpenAI(
42
+ api_key=config.api_key,
43
+ base_url=config.base_url,
44
+ default_headers=config.default_headers,
45
+ )
46
+
47
+ run_config = RunConfig(
48
+ task=kiln_task,
49
+ model_name=config.model_name,
50
+ model_provider_name=config.provider_name,
51
+ prompt_id=prompt_id or PromptGenerators.SIMPLE,
52
+ )
53
+
54
+ super().__init__(
55
+ run_config=run_config,
56
+ config=base_adapter_config,
57
+ )
58
+
59
+ async def _run(self, input: Dict | str) -> RunOutput:
60
+ provider = self.model_provider()
61
+ intermediate_outputs: dict[str, str] = {}
62
+ prompt = self.build_prompt()
63
+ user_msg = self.prompt_builder.build_user_message(input)
64
+ messages = [
65
+ ChatCompletionSystemMessageParam(role="system", content=prompt),
66
+ ChatCompletionUserMessageParam(role="user", content=user_msg),
67
+ ]
68
+
69
+ run_strategy, cot_prompt = self.run_strategy()
70
+
71
+ if run_strategy == "cot_as_message":
72
+ if not cot_prompt:
73
+ raise ValueError("cot_prompt is required for cot_as_message strategy")
74
+ messages.append(
75
+ ChatCompletionSystemMessageParam(role="system", content=cot_prompt)
76
+ )
77
+ elif run_strategy == "cot_two_call":
78
+ if not cot_prompt:
79
+ raise ValueError("cot_prompt is required for cot_two_call strategy")
80
+ messages.append(
81
+ ChatCompletionSystemMessageParam(role="system", content=cot_prompt)
82
+ )
83
+
84
+ # First call for chain of thought
85
+ cot_response = await self.client.chat.completions.create(
86
+ model=provider.provider_options["model"],
87
+ messages=messages,
88
+ )
89
+ cot_content = cot_response.choices[0].message.content
90
+ if cot_content is not None:
91
+ intermediate_outputs["chain_of_thought"] = cot_content
92
+
93
+ messages.extend(
94
+ [
95
+ ChatCompletionAssistantMessageParam(
96
+ role="assistant", content=cot_content
97
+ ),
98
+ ChatCompletionUserMessageParam(
99
+ role="user",
100
+ content=COT_FINAL_ANSWER_PROMPT,
101
+ ),
102
+ ]
103
+ )
104
+
105
+ # Build custom request params based on model provider
106
+ extra_body = self.build_extra_body(provider)
107
+
108
+ # Main completion call
109
+ response_format_options = await self.response_format_options()
110
+ response = await self.client.chat.completions.create(
111
+ model=provider.provider_options["model"],
112
+ messages=messages,
113
+ extra_body=extra_body,
114
+ logprobs=self.base_adapter_config.top_logprobs is not None,
115
+ top_logprobs=self.base_adapter_config.top_logprobs,
116
+ **response_format_options,
117
+ )
118
+
119
+ if not isinstance(response, ChatCompletion):
120
+ raise RuntimeError(
121
+ f"Expected ChatCompletion response, got {type(response)}."
122
+ )
123
+
124
+ if hasattr(response, "error") and response.error: # pyright: ignore
125
+ raise RuntimeError(
126
+ f"OpenAI compatible API returned status code {response.error.get('code')}: {response.error.get('message') or 'Unknown error'}.\nError: {response.error}" # pyright: ignore
127
+ )
128
+ if not response.choices or len(response.choices) == 0:
129
+ raise RuntimeError(
130
+ "No message content returned in the response from OpenAI compatible API"
131
+ )
132
+
133
+ message = response.choices[0].message
134
+ logprobs = response.choices[0].logprobs
135
+
136
+ # Check logprobs worked, if requested
137
+ if self.base_adapter_config.top_logprobs is not None and logprobs is None:
138
+ raise RuntimeError("Logprobs were required, but no logprobs were returned.")
139
+
140
+ # Save reasoning if it exists (OpenRouter specific api response field)
141
+ if provider.require_openrouter_reasoning:
142
+ if (
143
+ hasattr(message, "reasoning") and message.reasoning # pyright: ignore
144
+ ):
145
+ intermediate_outputs["reasoning"] = message.reasoning # pyright: ignore
146
+ else:
147
+ raise RuntimeError(
148
+ "Reasoning is required for this model, but no reasoning was returned from OpenRouter."
149
+ )
150
+
151
+ # the string content of the response
152
+ response_content = message.content
153
+
154
+ # Fallback: Use args of first tool call to task_response if it exists
155
+ if not response_content and message.tool_calls:
156
+ tool_call = next(
157
+ (
158
+ tool_call
159
+ for tool_call in message.tool_calls
160
+ if tool_call.function.name == "task_response"
161
+ ),
162
+ None,
163
+ )
164
+ if tool_call:
165
+ response_content = tool_call.function.arguments
166
+
167
+ if not isinstance(response_content, str):
168
+ raise RuntimeError(f"response is not a string: {response_content}")
169
+
170
+ # Parse to dict if we have structured output
171
+ output: Dict | str = response_content
172
+ if self.has_structured_output():
173
+ output = parse_json_string(response_content)
174
+
175
+ return RunOutput(
176
+ output=output,
177
+ intermediate_outputs=intermediate_outputs,
178
+ output_logprobs=logprobs,
179
+ )
180
+
181
+ def adapter_name(self) -> str:
182
+ return "kiln_openai_compatible_adapter"
183
+
184
+ async def response_format_options(self) -> dict[str, Any]:
185
+ # Unstructured if task isn't structured
186
+ if not self.has_structured_output():
187
+ return {}
188
+
189
+ provider = self.model_provider()
190
+ match provider.structured_output_mode:
191
+ case StructuredOutputMode.json_mode:
192
+ return {"response_format": {"type": "json_object"}}
193
+ case StructuredOutputMode.json_schema:
194
+ output_schema = self.task().output_schema()
195
+ return {
196
+ "response_format": {
197
+ "type": "json_schema",
198
+ "json_schema": {
199
+ "name": "task_response",
200
+ "schema": output_schema,
201
+ },
202
+ }
203
+ }
204
+ case StructuredOutputMode.function_calling_weak:
205
+ return self.tool_call_params(strict=False)
206
+ case StructuredOutputMode.function_calling:
207
+ return self.tool_call_params(strict=True)
208
+ case StructuredOutputMode.json_instructions:
209
+ # JSON done via instructions in prompt, not the API response format. Do not ask for json_object (see option below).
210
+ return {}
211
+ case StructuredOutputMode.json_instruction_and_object:
212
+ # We set response_format to json_object and also set json instructions in the prompt
213
+ return {"response_format": {"type": "json_object"}}
214
+ case StructuredOutputMode.default:
215
+ # Default to function calling -- it's older than the other modes. Higher compatibility.
216
+ return self.tool_call_params(strict=True)
217
+ case _:
218
+ raise_exhaustive_enum_error(provider.structured_output_mode)
219
+
220
+ def tool_call_params(self, strict: bool) -> dict[str, Any]:
221
+ # Add additional_properties: false to the schema (OpenAI requires this for some models)
222
+ output_schema = self.task().output_schema()
223
+ if not isinstance(output_schema, dict):
224
+ raise ValueError(
225
+ "Invalid output schema for this task. Can not use tool calls."
226
+ )
227
+ output_schema["additionalProperties"] = False
228
+
229
+ function_params = {
230
+ "name": "task_response",
231
+ "parameters": output_schema,
232
+ }
233
+ # This should be on, but we allow setting function_calling_weak for APIs that don't support it.
234
+ if strict:
235
+ function_params["strict"] = True
236
+
237
+ return {
238
+ "tools": [
239
+ {
240
+ "type": "function",
241
+ "function": function_params,
242
+ }
243
+ ],
244
+ "tool_choice": {
245
+ "type": "function",
246
+ "function": {"name": "task_response"},
247
+ },
248
+ }
249
+
250
+ def build_extra_body(self, provider: KilnModelProvider) -> dict[str, Any]:
251
+ # TODO P1: Don't love having this logic here. But it's a usability improvement
252
+ # so better to keep it than exclude it. Should figure out how I want to isolate
253
+ # this sort of logic so it's config driven and can be overridden
254
+
255
+ extra_body = {}
256
+ provider_options = {}
257
+
258
+ if provider.require_openrouter_reasoning:
259
+ # https://openrouter.ai/docs/use-cases/reasoning-tokens
260
+ extra_body["reasoning"] = {
261
+ "exclude": False,
262
+ }
263
+
264
+ if provider.r1_openrouter_options:
265
+ # Require providers that support the reasoning parameter
266
+ provider_options["require_parameters"] = True
267
+ # Prefer R1 providers with reasonable perf/quants
268
+ provider_options["order"] = ["Fireworks", "Together"]
269
+ # R1 providers with unreasonable quants
270
+ provider_options["ignore"] = ["DeepInfra"]
271
+
272
+ # Only set of this request is to get logprobs.
273
+ if (
274
+ provider.logprobs_openrouter_options
275
+ and self.base_adapter_config.top_logprobs is not None
276
+ ):
277
+ # Don't let OpenRouter choose a provider that doesn't support logprobs.
278
+ provider_options["require_parameters"] = True
279
+ # DeepInfra silently fails to return logprobs consistently.
280
+ provider_options["ignore"] = ["DeepInfra"]
281
+
282
+ if provider.openrouter_skip_required_parameters:
283
+ # Oddball case, R1 14/8/1.5B fail with this param, even though they support thinking params.
284
+ provider_options["require_parameters"] = False
285
+
286
+ if len(provider_options) > 0:
287
+ extra_body["provider"] = provider_options
288
+
289
+ return extra_body
@@ -0,0 +1,199 @@
1
+ from unittest.mock import MagicMock, patch
2
+
3
+ import pytest
4
+
5
+ from kiln_ai.adapters.ml_model_list import KilnModelProvider, StructuredOutputMode
6
+ from kiln_ai.adapters.model_adapters.base_adapter import BaseAdapter
7
+ from kiln_ai.datamodel import Task
8
+ from kiln_ai.datamodel.task import RunConfig
9
+
10
+
11
+ class MockAdapter(BaseAdapter):
12
+ """Concrete implementation of BaseAdapter for testing"""
13
+
14
+ async def _run(self, input):
15
+ return None
16
+
17
+ def adapter_name(self) -> str:
18
+ return "test"
19
+
20
+
21
+ @pytest.fixture
22
+ def mock_provider():
23
+ return KilnModelProvider(
24
+ name="openai",
25
+ )
26
+
27
+
28
+ @pytest.fixture
29
+ def base_task():
30
+ return Task(name="test_task", instruction="test_instruction")
31
+
32
+
33
+ @pytest.fixture
34
+ def adapter(base_task):
35
+ return MockAdapter(
36
+ run_config=RunConfig(
37
+ task=base_task,
38
+ model_name="test_model",
39
+ model_provider_name="test_provider",
40
+ prompt_id="simple_prompt_builder",
41
+ ),
42
+ )
43
+
44
+
45
+ async def test_model_provider_uses_cache(adapter, mock_provider):
46
+ """Test that cached provider is returned if it exists"""
47
+ # Set up cached provider
48
+ adapter._model_provider = mock_provider
49
+
50
+ # Mock the provider loader to ensure it's not called
51
+ with patch(
52
+ "kiln_ai.adapters.model_adapters.base_adapter.kiln_model_provider_from"
53
+ ) as mock_loader:
54
+ provider = adapter.model_provider()
55
+
56
+ assert provider == mock_provider
57
+ mock_loader.assert_not_called()
58
+
59
+
60
+ async def test_model_provider_loads_and_caches(adapter, mock_provider):
61
+ """Test that provider is loaded and cached if not present"""
62
+ # Ensure no cached provider
63
+ adapter._model_provider = None
64
+
65
+ # Mock the provider loader
66
+ with patch(
67
+ "kiln_ai.adapters.model_adapters.base_adapter.kiln_model_provider_from"
68
+ ) as mock_loader:
69
+ mock_loader.return_value = mock_provider
70
+
71
+ # First call should load and cache
72
+ provider1 = adapter.model_provider()
73
+ assert provider1 == mock_provider
74
+ mock_loader.assert_called_once_with("test_model", "test_provider")
75
+
76
+ # Second call should use cache
77
+ mock_loader.reset_mock()
78
+ provider2 = adapter.model_provider()
79
+ assert provider2 == mock_provider
80
+ mock_loader.assert_not_called()
81
+
82
+
83
+ async def test_model_provider_missing_names(base_task):
84
+ """Test error when model or provider name is missing"""
85
+ # Test with missing model name
86
+ adapter = MockAdapter(
87
+ run_config=RunConfig(
88
+ task=base_task,
89
+ model_name="",
90
+ model_provider_name="",
91
+ prompt_id="simple_prompt_builder",
92
+ ),
93
+ )
94
+ with pytest.raises(
95
+ ValueError, match="model_name and model_provider_name must be provided"
96
+ ):
97
+ await adapter.model_provider()
98
+
99
+ # Test with missing provider name
100
+ adapter = MockAdapter(
101
+ run_config=RunConfig(
102
+ task=base_task,
103
+ model_name="test_model",
104
+ model_provider_name="",
105
+ prompt_id="simple_prompt_builder",
106
+ ),
107
+ )
108
+ with pytest.raises(
109
+ ValueError, match="model_name and model_provider_name must be provided"
110
+ ):
111
+ await adapter.model_provider()
112
+
113
+
114
+ async def test_model_provider_not_found(adapter):
115
+ """Test error when provider loader returns None"""
116
+ # Mock the provider loader to return None
117
+ with patch(
118
+ "kiln_ai.adapters.model_adapters.base_adapter.kiln_model_provider_from"
119
+ ) as mock_loader:
120
+ mock_loader.return_value = None
121
+
122
+ with pytest.raises(
123
+ ValueError,
124
+ match="model_provider_name test_provider not found for model test_model",
125
+ ):
126
+ await adapter.model_provider()
127
+
128
+
129
+ @pytest.mark.asyncio
130
+ @pytest.mark.parametrize(
131
+ "output_schema,structured_output_mode,expected_json_instructions",
132
+ [
133
+ (False, StructuredOutputMode.json_instructions, False),
134
+ (True, StructuredOutputMode.json_instructions, True),
135
+ (False, StructuredOutputMode.json_instruction_and_object, False),
136
+ (True, StructuredOutputMode.json_instruction_and_object, True),
137
+ (True, StructuredOutputMode.json_mode, False),
138
+ (False, StructuredOutputMode.json_mode, False),
139
+ ],
140
+ )
141
+ async def test_prompt_builder_json_instructions(
142
+ base_task,
143
+ adapter,
144
+ output_schema,
145
+ structured_output_mode,
146
+ expected_json_instructions,
147
+ ):
148
+ """Test that prompt builder is called with correct include_json_instructions value"""
149
+ # Mock the prompt builder and has_structured_output method
150
+ mock_prompt_builder = MagicMock()
151
+ adapter.prompt_builder = mock_prompt_builder
152
+ adapter.model_provider_name = "openai"
153
+ adapter.has_structured_output = MagicMock(return_value=output_schema)
154
+
155
+ # provider mock
156
+ provider = MagicMock()
157
+ provider.structured_output_mode = structured_output_mode
158
+ adapter.model_provider = MagicMock(return_value=provider)
159
+
160
+ # Test
161
+ adapter.build_prompt()
162
+ mock_prompt_builder.build_prompt.assert_called_with(
163
+ include_json_instructions=expected_json_instructions
164
+ )
165
+
166
+
167
+ @pytest.mark.parametrize(
168
+ "cot_prompt,has_structured_output,reasoning_capable,expected",
169
+ [
170
+ # COT and normal LLM
171
+ ("think carefully", False, False, ("cot_two_call", "think carefully")),
172
+ # Structured output with thinking-capable LLM
173
+ ("think carefully", True, True, ("cot_as_message", "think carefully")),
174
+ # Structured output with normal LLM
175
+ ("think carefully", True, False, ("cot_two_call", "think carefully")),
176
+ # Basic cases - no COT
177
+ (None, True, True, ("basic", None)),
178
+ (None, False, False, ("basic", None)),
179
+ (None, True, False, ("basic", None)),
180
+ (None, False, True, ("basic", None)),
181
+ # Edge case - COT prompt exists but structured output is False and reasoning_capable is True
182
+ ("think carefully", False, True, ("cot_as_message", "think carefully")),
183
+ ],
184
+ )
185
+ async def test_run_strategy(
186
+ adapter, cot_prompt, has_structured_output, reasoning_capable, expected
187
+ ):
188
+ """Test that run_strategy returns correct strategy based on conditions"""
189
+ # Mock dependencies
190
+ adapter.prompt_builder.chain_of_thought_prompt = MagicMock(return_value=cot_prompt)
191
+ adapter.has_structured_output = MagicMock(return_value=has_structured_output)
192
+
193
+ provider = MagicMock()
194
+ provider.reasoning_capable = reasoning_capable
195
+ adapter.model_provider = MagicMock(return_value=provider)
196
+
197
+ # Test
198
+ result = adapter.run_strategy()
199
+ assert result == expected