kiln_ai.datamodel
See our docs for details about our datamodel classes and hierarchy:
Developer docs: https://kiln-ai.github.io/Kiln/kiln_core_docs/kiln_ai.html
1""" 2See our docs for details about our datamodel classes and hierarchy: 3 4Developer docs: https://kiln-ai.github.io/Kiln/kiln_core_docs/kiln_ai.html 5 6User docs: https://docs.kiln.tech/developers/kiln-datamodel 7""" 8 9# This component uses "flat" imports so we don't have too much internal structure exposed in the API. 10# for example you can just `from datamodel import Task, Project` instead of `from datamodel.task import Task; from datamodel.project import Project` 11 12from __future__ import annotations 13 14from kiln_ai.datamodel import ( 15 chunk, 16 dataset_split, 17 embedding, 18 eval, 19 extraction, 20 rag, 21 reranker, 22 strict_mode, 23) 24from kiln_ai.datamodel.basemodel import generate_model_id 25from kiln_ai.datamodel.code_tool import CodeTool 26from kiln_ai.datamodel.data_guide import DataGuide 27from kiln_ai.datamodel.datamodel_enums import ( 28 FeedbackSource, 29 FineTuneStatusType, 30 Priority, 31 StructuredOutputMode, 32 TaskOutputRatingType, 33) 34from kiln_ai.datamodel.dataset_split import DatasetSplit, DatasetSplitDefinition 35from kiln_ai.datamodel.external_tool_server import ExternalToolServer 36from kiln_ai.datamodel.feedback import Feedback 37from kiln_ai.datamodel.finetune import Finetune 38from kiln_ai.datamodel.project import Project 39from kiln_ai.datamodel.prompt import BasePrompt, Prompt 40from kiln_ai.datamodel.prompt_id import ( 41 PromptGenerators, 42 PromptId, 43 prompt_generator_values, 44) 45from kiln_ai.datamodel.prompt_optimization_job import PromptOptimizationJob 46from kiln_ai.datamodel.skill import Skill 47from kiln_ai.datamodel.task import Task, TaskRequirement 48from kiln_ai.datamodel.task_output import ( 49 DataSource, 50 DataSourceProperty, 51 DataSourceType, 52 RequirementRating, 53 TaskOutput, 54 TaskOutputRating, 55) 56from kiln_ai.datamodel.task_run import ( 57 EvalItemSource, 58 MessageUsage, 59 TaskRun, 60 Usage, 61) 62 63__all__ = [ 64 "BasePrompt", 65 "CodeTool", 66 "DataGuide", 67 "DataSource", 68 "DataSourceProperty", 69 "DataSourceType", 70 "DatasetSplit", 71 "DatasetSplitDefinition", 72 "EvalItemSource", 73 "ExternalToolServer", 74 "Feedback", 75 "FeedbackSource", 76 "FineTuneStatusType", 77 "Finetune", 78 "MessageUsage", 79 "Priority", 80 "Project", 81 "Prompt", 82 "PromptGenerators", 83 "PromptId", 84 "PromptOptimizationJob", 85 "RequirementRating", 86 "Skill", 87 "StructuredOutputMode", 88 "Task", 89 "TaskOutput", 90 "TaskOutputRating", 91 "TaskOutputRatingType", 92 "TaskRequirement", 93 "TaskRun", 94 "Usage", 95 "chunk", 96 "dataset_split", 97 "embedding", 98 "eval", 99 "extraction", 100 "generate_model_id", 101 "prompt_generator_values", 102 "rag", 103 "reranker", 104 "strict_mode", 105]
7class BasePrompt(BaseModel): 8 """ 9 A prompt for a task. This is the basic data storage format which can be used throughout a project. 10 11 The "Prompt" model name is reserved for the custom prompts parented by a task. 12 """ 13 14 name: FilenameString = Field(description="The name of the prompt.") 15 description: str | None = Field( 16 default=None, 17 description="A more detailed description of the prompt.", 18 ) 19 generator_id: str | None = Field( 20 default=None, 21 description="The id of the generator that created this prompt.", 22 ) 23 prompt: str = Field( 24 description="The prompt for the task.", 25 min_length=1, 26 ) 27 chain_of_thought_instructions: str | None = Field( 28 default=None, 29 description="Instructions for the model 'thinking' about the requirement prior to answering. Used for chain of thought style prompting. COT will not be used unless this is provided.", 30 )
A prompt for a task. This is the basic data storage format which can be used throughout a project.
The "Prompt" model name is reserved for the custom prompts parented by a task.
38class CodeTool(KilnParentedModel): 39 """A user-authored Python function that runs as a tool inside the agent harness. 40 41 Functional content (code, schema, allowlist, etc.) is immutable post-create 42 — the API enforces this; changing code means cloning into a new tool. 43 """ 44 45 # Editable metadata 46 name: FilenameString = Field(description="User-facing display name.") 47 description: str | None = Field( 48 default=None, 49 description="User-facing notes shown in the UI. Not shown to models.", 50 ) 51 is_archived: bool = Field( 52 default=False, 53 description="Archived tools are hidden from pickers but still resolve if referenced.", 54 ) 55 56 # Functional content — immutable post-create (enforced at the API layer) 57 tool_function_name: str = Field( 58 description="The function name exposed to the model. Snake_case identifier." 59 ) 60 tool_description: str = Field( 61 min_length=1, 62 description="Shown to agents as the tool description.", 63 ) 64 parameters_schema: dict[str, Any] = Field( 65 description="JSON Schema for the tool's parameters. Root must be type: object.", 66 ) 67 code: str = Field( 68 description="Python source, stored in a sibling tool.py file (in memory as a string). Validated for syntax and entry-point presence.", 69 ) 70 timeout_seconds: int = Field( 71 default=60, 72 ge=1, 73 description="Wall-clock timeout for one invocation, including nested tool calls.", 74 ) 75 tool_allowlist: list[ToolId] = Field( 76 default_factory=list, 77 description="Explicit per-tool allowlist of tools this code tool may call.", 78 ) 79 80 @model_validator(mode="before") 81 @classmethod 82 def _read_code_file(cls, data: Any, info: ValidationInfo) -> Any: 83 """When loading from disk, inject `code` from the sibling tool.py. 84 85 The source is stored in tool.py beside code_tool.kiln, not inline in the 86 JSON. On load the base model puts the artifact folder in the validation 87 context (`source_dir`); the shared helper reads the file here, before 88 field validation, so the existing validate_code trio runs against the 89 loaded string unchanged. 90 """ 91 return read_code_from_sibling_file( 92 data, 93 info.context or {}, 94 filename=TOOL_CODE_FILENAME, 95 kiln_filename="code_tool.kiln", 96 model_label="CodeTool", 97 ) 98 99 @model_serializer(mode="wrap") 100 def _serialize( 101 self, handler: SerializerFunctionWrapHandler, info: SerializationInfo 102 ) -> dict[str, Any]: 103 """On disk-save, write `code` to tool.py and omit it from the .kiln JSON. 104 105 Delegates to the shared sibling-file helper, which uses the same save 106 context attachments use (`save_attachments` + `dest_path`). Without that 107 context — normal model_dump / API responses — `code` is left in the 108 output and no file is written, so the API contract is unchanged. 109 110 Trade-off: a custom model_serializer collapses the *serialization-mode* 111 JSON schema to an untyped object (`model_json_schema(mode="serialization")` 112 loses per-field typing). This is acceptable and consistent with the 113 existing KilnAttachmentModel precedent, which uses the same pattern: 114 - Validation-mode schema is unaffected, so request bodies stay fully typed. 115 - No endpoint uses `response_model=CodeTool`; every code-tool endpoint 116 returns a dedicated response model, and the generated web schema never 117 references CodeTool's serialization schema. 118 If a typed serialization schema is ever needed off this model, add a 119 `__get_pydantic_json_schema__` override rather than removing this 120 serializer. (CodeEvalProperties keeps exactly such an override because it 121 IS a FastAPI response_model member.) 122 """ 123 return write_code_to_sibling_file( 124 handler(self), 125 info.context or {}, 126 filename=TOOL_CODE_FILENAME, 127 code=self.code, 128 ) 129 130 @field_validator("tool_function_name") 131 @classmethod 132 def validate_function_name(cls, v: str) -> str: 133 if not _FUNCTION_NAME_RE.fullmatch(v): 134 raise ValueError( 135 f"tool_function_name must match ^[a-z][a-z0-9_]{{0,63}}$, got: '{v}'" 136 ) 137 return v 138 139 @model_validator(mode="after") 140 def validate_parameters_schema(self) -> Self: 141 validate_schema_dict(self.parameters_schema, require_object=True) 142 return self 143 144 @model_validator(mode="after") 145 def validate_code(self) -> Self: 146 code_bytes = self.code.encode("utf-8") 147 if len(code_bytes) > 64 * 1024: 148 raise ValueError( 149 f"Code is too large ({len(code_bytes)} bytes). Maximum size is 64KB." 150 ) 151 152 try: 153 compile(self.code, "<code_tool>", "exec") 154 except SyntaxError as e: 155 raise ValueError(f"Code has a syntax error: {e}") from e 156 157 tree = ast.parse(self.code) 158 has_run_fn = any( 159 isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) 160 and node.name == "run" 161 for node in ast.iter_child_nodes(tree) 162 ) 163 if not has_run_fn: 164 raise ValueError( 165 "Code must define a module-level 'run' function (def run(...) or async def run(...))." 166 ) 167 168 return self 169 170 @model_validator(mode="after") 171 def validate_allowlist(self) -> Self: 172 validate_tool_allowlist( 173 self.tool_allowlist, 174 caller="code tools", 175 self_tool_id=build_code_tool_id(self.id) if self.id is not None else None, 176 ) 177 return self
A user-authored Python function that runs as a tool inside the agent harness.
Functional content (code, schema, allowlist, etc.) is immutable post-create — the API enforces this; changing code means cloning into a new tool.
144 @model_validator(mode="after") 145 def validate_code(self) -> Self: 146 code_bytes = self.code.encode("utf-8") 147 if len(code_bytes) > 64 * 1024: 148 raise ValueError( 149 f"Code is too large ({len(code_bytes)} bytes). Maximum size is 64KB." 150 ) 151 152 try: 153 compile(self.code, "<code_tool>", "exec") 154 except SyntaxError as e: 155 raise ValueError(f"Code has a syntax error: {e}") from e 156 157 tree = ast.parse(self.code) 158 has_run_fn = any( 159 isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) 160 and node.name == "run" 161 for node in ast.iter_child_nodes(tree) 162 ) 163 if not has_run_fn: 164 raise ValueError( 165 "Code must define a module-level 'run' function (def run(...) or async def run(...))." 166 ) 167 168 return self
The type of the None singleton.
Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
365def init_private_attributes(self: BaseModel, context: Any, /) -> None: 366 """This function is meant to behave like a BaseModel method to initialize private attributes. 367 368 It takes context as an argument since that's what pydantic-core passes when calling it. 369 370 Args: 371 self: The BaseModel instance. 372 context: The context. 373 """ 374 if getattr(self, '__pydantic_private__', None) is None: 375 pydantic_private = {} 376 for name, private_attr in self.__private_attributes__.items(): 377 # Avoid needlessly creating a new dict for the validated data: 378 if private_attr.default_factory_takes_validated_data: 379 default = private_attr.get_default( 380 call_default_factory=True, validated_data={**self.__dict__, **pydantic_private} 381 ) 382 else: 383 default = private_attr.get_default(call_default_factory=True) 384 if default is not PydanticUndefined: 385 pydantic_private[name] = default 386 object_setattr(self, '__pydantic_private__', pydantic_private)
This function is meant to behave like a BaseModel method to initialize private attributes.
It takes context as an argument since that's what pydantic-core passes when calling it.
Args: self: The BaseModel instance. context: The context.
11class DataGuide(KilnParentedModel): 12 """Persistent input data guide for synthetic data generation, stored as a 13 child of a Task. 14 15 The guide describes what realistic *inputs* to this task look like — input 16 shape, format, distribution, and the kinds of values inputs contain. It is 17 consumed at the topic and input generation stages of synthetic data 18 generation, never at the output stage. Output behavior is the job of the 19 task's system prompt, not this guide. 20 21 The guide is a single markdown body. Two canonical shapes are supported 22 depending on origin: 23 24 - **Manual flow** (user-authored examples): leads with `# Reference Inputs` 25 (user-owned `## Example N` blocks), followed by `# Semantics`, `# Style`, 26 `# Presentation Defaults`. 27 - **Kiln Pro / Copilot flow** (analyze pipeline): only `# Semantics`, 28 `# Style`, `# Presentation Defaults` — the analyze prompt derives rules 29 from input documents rather than quoting them, matching Mike's 30 GENERATE_CORPUS_GUIDELINES vocabulary. 31 32 The metaprompter treats the whole body as one editable artifact and returns 33 a refined version on each refine pass; refine auto-detects which shape it 34 is working on by checking for a `# Reference Inputs` heading. 35 """ 36 37 guide: str = Field( 38 default="", 39 description="Markdown body of the input data guide. Manual-flow guides start with `# Reference Inputs`; Kiln Pro / Copilot-flow guides have only `# Semantics`, `# Style`, `# Presentation Defaults`.", 40 ) 41 42 source: DataGuideSource = Field( 43 default="manual", 44 description="Which flow created this guide. Refine + verify pipelines branch on this to choose the right metaprompter (manual = user-curated examples + edits all sections; kiln_pro = LLM-derived, refine is feedback-only surgical edits).", 45 )
Persistent input data guide for synthetic data generation, stored as a child of a Task.
The guide describes what realistic inputs to this task look like — input shape, format, distribution, and the kinds of values inputs contain. It is consumed at the topic and input generation stages of synthetic data generation, never at the output stage. Output behavior is the job of the task's system prompt, not this guide.
The guide is a single markdown body. Two canonical shapes are supported depending on origin:
- Manual flow (user-authored examples): leads with
# Reference Inputs(user-owned## Example Nblocks), followed by# Semantics,# Style,# Presentation Defaults. - Kiln Pro / Copilot flow (analyze pipeline): only
# Semantics,# Style,# Presentation Defaults— the analyze prompt derives rules from input documents rather than quoting them, matching Mike's GENERATE_CORPUS_GUIDELINES vocabulary.
The metaprompter treats the whole body as one editable artifact and returns
a refined version on each refine pass; refine auto-detects which shape it
is working on by checking for a # Reference Inputs heading.
The type of the None singleton.
Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
365def init_private_attributes(self: BaseModel, context: Any, /) -> None: 366 """This function is meant to behave like a BaseModel method to initialize private attributes. 367 368 It takes context as an argument since that's what pydantic-core passes when calling it. 369 370 Args: 371 self: The BaseModel instance. 372 context: The context. 373 """ 374 if getattr(self, '__pydantic_private__', None) is None: 375 pydantic_private = {} 376 for name, private_attr in self.__private_attributes__.items(): 377 # Avoid needlessly creating a new dict for the validated data: 378 if private_attr.default_factory_takes_validated_data: 379 default = private_attr.get_default( 380 call_default_factory=True, validated_data={**self.__dict__, **pydantic_private} 381 ) 382 else: 383 default = private_attr.get_default(call_default_factory=True) 384 if default is not PydanticUndefined: 385 pydantic_private[name] = default 386 object_setattr(self, '__pydantic_private__', pydantic_private)
This function is meant to behave like a BaseModel method to initialize private attributes.
It takes context as an argument since that's what pydantic-core passes when calling it.
Args: self: The BaseModel instance. context: The context.
195class DataSource(BaseModel): 196 """ 197 Represents the origin of data, either human, synthetic, file import, or tool call, with associated properties. 198 199 Properties vary based on the source type - for synthetic/tool_call sources this includes 200 model information, for human sources this includes creator information, for file imports 201 this includes file information. 202 """ 203 204 type: DataSourceType = Field(description="The type of data source.") 205 properties: Dict[str, str | int | float] = Field( 206 default={}, 207 description="Properties describing the data source. For synthetic things like model. For human: the human's name. For file_import: file information.", 208 ) 209 run_config_id: Optional[str] = Field( 210 default=None, 211 description="The ID of the saved TaskRunConfig used to produce this data, if any. Only present when the run was initiated from a saved TaskRunConfig (e.g. via the saved-config dropdown or a tool call); runs configured ad-hoc from the run page leave this unset. Not validated against the file system: a TaskRunConfig may be deleted without invalidating historical runs that reference it.", 212 ) 213 run_config: Optional[RunConfigProperties] = Field( 214 default=None, 215 description="The run config used to generate the data, if generated by a running a model in Kiln (only true for type=synthetic).", 216 ) 217 218 @model_validator(mode="after") 219 def normalize_empty_run_config_id(self) -> Self: 220 # Some callers (e.g. tool wrappers reading from external properties) 221 # default missing IDs to "". Coerce to None so this field is either a 222 # real ID or unset — never an ambiguous empty string on disk. 223 if self.run_config_id == "": 224 self.run_config_id = None 225 return self 226 227 _data_source_properties = [ 228 DataSourceProperty( 229 name="created_by", 230 type=str, 231 required_for=[DataSourceType.human], 232 not_allowed_for=[ 233 DataSourceType.synthetic, 234 DataSourceType.file_import, 235 DataSourceType.tool_call, 236 ], 237 ), 238 DataSourceProperty( 239 name="model_name", 240 type=str, 241 required_for=[DataSourceType.synthetic], 242 not_allowed_for=[ 243 DataSourceType.human, 244 DataSourceType.file_import, 245 DataSourceType.tool_call, 246 ], 247 ), 248 DataSourceProperty( 249 name="model_provider", 250 type=str, 251 required_for=[DataSourceType.synthetic], 252 not_allowed_for=[ 253 DataSourceType.human, 254 DataSourceType.file_import, 255 DataSourceType.tool_call, 256 ], 257 ), 258 DataSourceProperty( 259 name="adapter_name", 260 type=str, 261 required_for=[DataSourceType.synthetic], 262 not_allowed_for=[ 263 DataSourceType.human, 264 DataSourceType.file_import, 265 DataSourceType.tool_call, 266 ], 267 ), 268 DataSourceProperty( 269 # Legacy field -- allow loading from old runs, but we shouldn't be setting it. 270 name="prompt_builder_name", 271 type=str, 272 not_allowed_for=[ 273 DataSourceType.human, 274 DataSourceType.file_import, 275 DataSourceType.tool_call, 276 ], 277 ), 278 DataSourceProperty( 279 # The PromptId of the prompt. Can be a saved prompt, fine-tune, generator name, etc. See PromptId type for more details. 280 name="prompt_id", 281 type=str, 282 not_allowed_for=[ 283 DataSourceType.human, 284 DataSourceType.file_import, 285 DataSourceType.tool_call, 286 ], 287 ), 288 DataSourceProperty( 289 name="file_name", 290 type=str, 291 required_for=[DataSourceType.file_import], 292 not_allowed_for=[ 293 DataSourceType.human, 294 DataSourceType.synthetic, 295 DataSourceType.tool_call, 296 ], 297 ), 298 ] 299 300 @model_validator(mode="after") 301 def validate_type(self) -> "DataSource": 302 if self.type not in DataSourceType: 303 raise ValueError(f"Invalid data source type: {self.type}") 304 return self 305 306 @model_validator(mode="after") 307 def validate_properties(self) -> "DataSource": 308 for prop in self._data_source_properties: 309 # Check the property type is correct 310 if prop.name in self.properties: 311 if not isinstance(self.properties[prop.name], prop.type): 312 raise ValueError( 313 f"'{prop.name}' must be of type {prop.type.__name__} for {self.type} data source" 314 ) 315 # Check the property is required for the data source type 316 if self.type in prop.required_for: 317 if prop.name not in self.properties: 318 raise ValueError( 319 f"'{prop.name}' is required for {self.type} data source" 320 ) 321 # Check the property is not allowed for the data source type 322 elif self.type in prop.not_allowed_for and prop.name in self.properties: 323 raise ValueError( 324 f"'{prop.name}' is not allowed for {self.type} data source" 325 ) 326 return self 327 328 @model_validator(mode="after") 329 def validate_no_empty_properties(self) -> Self: 330 for prop, value in self.properties.items(): 331 if isinstance(value, str) and value == "": 332 raise ValueError( 333 f"Property '{prop}' must be a non-empty string for {self.type} data source" 334 ) 335 return self
Represents the origin of data, either human, synthetic, file import, or tool call, with associated properties.
Properties vary based on the source type - for synthetic/tool_call sources this includes model information, for human sources this includes creator information, for file imports this includes file information.
218 @model_validator(mode="after") 219 def normalize_empty_run_config_id(self) -> Self: 220 # Some callers (e.g. tool wrappers reading from external properties) 221 # default missing IDs to "". Coerce to None so this field is either a 222 # real ID or unset — never an ambiguous empty string on disk. 223 if self.run_config_id == "": 224 self.run_config_id = None 225 return self
306 @model_validator(mode="after") 307 def validate_properties(self) -> "DataSource": 308 for prop in self._data_source_properties: 309 # Check the property type is correct 310 if prop.name in self.properties: 311 if not isinstance(self.properties[prop.name], prop.type): 312 raise ValueError( 313 f"'{prop.name}' must be of type {prop.type.__name__} for {self.type} data source" 314 ) 315 # Check the property is required for the data source type 316 if self.type in prop.required_for: 317 if prop.name not in self.properties: 318 raise ValueError( 319 f"'{prop.name}' is required for {self.type} data source" 320 ) 321 # Check the property is not allowed for the data source type 322 elif self.type in prop.not_allowed_for and prop.name in self.properties: 323 raise ValueError( 324 f"'{prop.name}' is not allowed for {self.type} data source" 325 ) 326 return self
328 @model_validator(mode="after") 329 def validate_no_empty_properties(self) -> Self: 330 for prop, value in self.properties.items(): 331 if isinstance(value, str) and value == "": 332 raise ValueError( 333 f"Property '{prop}' must be a non-empty string for {self.type} data source" 334 ) 335 return self
Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
365def init_private_attributes(self: BaseModel, context: Any, /) -> None: 366 """This function is meant to behave like a BaseModel method to initialize private attributes. 367 368 It takes context as an argument since that's what pydantic-core passes when calling it. 369 370 Args: 371 self: The BaseModel instance. 372 context: The context. 373 """ 374 if getattr(self, '__pydantic_private__', None) is None: 375 pydantic_private = {} 376 for name, private_attr in self.__private_attributes__.items(): 377 # Avoid needlessly creating a new dict for the validated data: 378 if private_attr.default_factory_takes_validated_data: 379 default = private_attr.get_default( 380 call_default_factory=True, validated_data={**self.__dict__, **pydantic_private} 381 ) 382 else: 383 default = private_attr.get_default(call_default_factory=True) 384 if default is not PydanticUndefined: 385 pydantic_private[name] = default 386 object_setattr(self, '__pydantic_private__', pydantic_private)
This function is meant to behave like a BaseModel method to initialize private attributes.
It takes context as an argument since that's what pydantic-core passes when calling it.
Args: self: The BaseModel instance. context: The context.
181class DataSourceProperty(BaseModel): 182 """ 183 Defines a property that can be associated with a data source. 184 185 Includes validation rules for when properties are required or not allowed 186 based on the data source type. 187 """ 188 189 name: str 190 type: Type[Union[str, int, float]] 191 required_for: List[DataSourceType] = [] 192 not_allowed_for: List[DataSourceType] = []
Defines a property that can be associated with a data source.
Includes validation rules for when properties are required or not allowed based on the data source type.
167class DataSourceType(str, Enum): 168 """ 169 The source type of a piece of data. 170 171 Human: a human created the data 172 Synthetic: a model created the data 173 """ 174 175 human = "human" 176 synthetic = "synthetic" 177 file_import = "file_import" 178 tool_call = "tool_call"
The source type of a piece of data.
Human: a human created the data Synthetic: a model created the data
83class DatasetSplit(KilnParentedModel): 84 """ 85 A collection of task runs, with optional splits (train, test, validation). 86 87 Used to freeze a dataset into train/test/validation splits for repeatable fine-tuning or other tasks. 88 89 Maintains a list of IDs for each split, to avoid data duplication. 90 """ 91 92 name: FilenameString = Field(description="The name of the dataset split.") 93 description: str | None = Field( 94 default=None, 95 description="A description of the dataset for you and your team. Not used in training.", 96 ) 97 splits: list[DatasetSplitDefinition] = Field( 98 default_factory=list, 99 description="The splits in the dataset.", 100 ) 101 split_contents: dict[str, list[str]] = Field( 102 description="The contents of each split in the dataset. The key is the split name, and the value is a list of task run IDs.", 103 ) 104 filter: DatasetFilterId | None = Field( 105 default=None, 106 description="The filter used to build the dataset.", 107 ) 108 109 @model_validator(mode="after") 110 def validate_split_percentages(self) -> "DatasetSplit": 111 total = sum(split.percentage for split in self.splits) 112 if not math.isclose(total, 1.0, rel_tol=1e-9): 113 raise ValueError(f"The sum of split percentages must be 1.0 (got {total})") 114 return self 115 116 @classmethod 117 def from_task( 118 cls, 119 name: str, 120 task: "Task", 121 splits: list[DatasetSplitDefinition], 122 filter_id: DatasetFilterId = "all", 123 description: str | None = None, 124 ): 125 """ 126 Build a dataset split from a task. 127 """ 128 filter = dataset_filter_from_id(filter_id) 129 split_contents = cls.build_split_contents(task, splits, filter) 130 return cls( 131 parent=task, 132 name=name, 133 description=description, 134 splits=splits, 135 split_contents=split_contents, 136 filter=filter_id, 137 ) 138 139 @classmethod 140 def build_split_contents( 141 cls, 142 task: "Task", 143 splits: list[DatasetSplitDefinition], 144 filter: DatasetFilter, 145 ) -> dict[str, list[str]]: 146 valid_ids = [] 147 for task_run in task.runs(): 148 if filter(task_run): 149 valid_ids.append(task_run.id) 150 151 # Shuffle and split by split percentage 152 random.shuffle(valid_ids) 153 split_contents = {} 154 start_idx = 0 155 remaining_items = len(valid_ids) 156 157 # Handle all splits except the last one 158 for split in splits[:-1]: 159 split_size = round(len(valid_ids) * split.percentage) 160 split_contents[split.name] = valid_ids[start_idx : start_idx + split_size] 161 start_idx += split_size 162 remaining_items -= split_size 163 164 # Last split gets all remaining items (for rounding) 165 if splits: 166 split_contents[splits[-1].name] = valid_ids[start_idx:] 167 168 return split_contents 169 170 def parent_task(self) -> "Task | None": 171 # inline import to avoid circular import 172 from kiln_ai.datamodel import Task 173 174 if not isinstance(self.parent, Task): 175 return None 176 return self.parent 177 178 def missing_count(self) -> int: 179 """ 180 Returns: 181 int: the number of task runs that have an ID persisted in this dataset split, but no longer exist in the dataset 182 """ 183 parent = self.parent_task() 184 if parent is None: 185 raise ValueError("DatasetSplit has no parent task") 186 187 runs = parent.runs(readonly=True) 188 all_ids = set(run.id for run in runs) 189 all_ids_in_splits = set() 190 for ids in self.split_contents.values(): 191 all_ids_in_splits.update(ids) 192 missing = all_ids_in_splits - all_ids 193 return len(missing) 194 195 def _get_runs(self) -> list[TaskRun]: 196 """ 197 Get all task runs referenced in this dataset split. 198 199 Returns: 200 list[TaskRun]: list of task runs in this dataset split 201 """ 202 parent = self.parent_task() 203 if parent is None: 204 return [] 205 206 runs = [] 207 all_run_ids = set() 208 for run_ids in self.split_contents.values(): 209 all_run_ids.update(run_ids) 210 211 # Find all runs by their IDs 212 for task_run in parent.runs(readonly=True): 213 if task_run.id in all_run_ids: 214 runs.append(task_run) 215 216 return runs 217 218 @staticmethod 219 def compute_tool_info(runs: list[TaskRun]) -> DatasetToolInfo: 220 """ 221 Compute tool info from a list of task runs. 222 223 Args: 224 runs: list of task runs to analyze 225 226 Returns: 227 DatasetToolInfo: information about tools used across the task runs 228 """ 229 230 has_tool_mismatch = False 231 tools: set[str] | None = None 232 233 for run in runs: 234 # Extract tools from run config, treating missing source/run_config/tools_config as empty tools 235 run_tools: set[str] = set() 236 source = run.output.source if run.output else None 237 if source is not None and isinstance( 238 source.run_config, KilnAgentRunConfigProperties 239 ): 240 tools_config = source.run_config.tools_config 241 if tools_config is not None: 242 run_tools = set(tools_config.tools) 243 244 # First run establishes the expected tool set (including empty) 245 if tools is None: 246 tools = run_tools 247 elif run_tools != tools: 248 # Mismatch found 249 has_tool_mismatch = True 250 tools = None 251 break 252 253 # If no valid runs were processed, return empty tools 254 if tools is None: 255 if not has_tool_mismatch: 256 tools = set() 257 258 return DatasetToolInfo( 259 has_tool_mismatch=has_tool_mismatch, 260 tools=None if tools is None else sorted(tools), 261 ) 262 263 def tool_info(self) -> DatasetToolInfo: 264 """ 265 Helper method to compute tool info for the dataset split. Iterate through all runs in the dataset split and check the tools used in each run config. 266 267 Returns: 268 DatasetToolInfo: information about tools used across task runs in this dataset split 269 """ 270 runs = self._get_runs() 271 tool_info = self.compute_tool_info(runs) 272 return tool_info
A collection of task runs, with optional splits (train, test, validation).
Used to freeze a dataset into train/test/validation splits for repeatable fine-tuning or other tasks.
Maintains a list of IDs for each split, to avoid data duplication.
109 @model_validator(mode="after") 110 def validate_split_percentages(self) -> "DatasetSplit": 111 total = sum(split.percentage for split in self.splits) 112 if not math.isclose(total, 1.0, rel_tol=1e-9): 113 raise ValueError(f"The sum of split percentages must be 1.0 (got {total})") 114 return self
116 @classmethod 117 def from_task( 118 cls, 119 name: str, 120 task: "Task", 121 splits: list[DatasetSplitDefinition], 122 filter_id: DatasetFilterId = "all", 123 description: str | None = None, 124 ): 125 """ 126 Build a dataset split from a task. 127 """ 128 filter = dataset_filter_from_id(filter_id) 129 split_contents = cls.build_split_contents(task, splits, filter) 130 return cls( 131 parent=task, 132 name=name, 133 description=description, 134 splits=splits, 135 split_contents=split_contents, 136 filter=filter_id, 137 )
Build a dataset split from a task.
139 @classmethod 140 def build_split_contents( 141 cls, 142 task: "Task", 143 splits: list[DatasetSplitDefinition], 144 filter: DatasetFilter, 145 ) -> dict[str, list[str]]: 146 valid_ids = [] 147 for task_run in task.runs(): 148 if filter(task_run): 149 valid_ids.append(task_run.id) 150 151 # Shuffle and split by split percentage 152 random.shuffle(valid_ids) 153 split_contents = {} 154 start_idx = 0 155 remaining_items = len(valid_ids) 156 157 # Handle all splits except the last one 158 for split in splits[:-1]: 159 split_size = round(len(valid_ids) * split.percentage) 160 split_contents[split.name] = valid_ids[start_idx : start_idx + split_size] 161 start_idx += split_size 162 remaining_items -= split_size 163 164 # Last split gets all remaining items (for rounding) 165 if splits: 166 split_contents[splits[-1].name] = valid_ids[start_idx:] 167 168 return split_contents
178 def missing_count(self) -> int: 179 """ 180 Returns: 181 int: the number of task runs that have an ID persisted in this dataset split, but no longer exist in the dataset 182 """ 183 parent = self.parent_task() 184 if parent is None: 185 raise ValueError("DatasetSplit has no parent task") 186 187 runs = parent.runs(readonly=True) 188 all_ids = set(run.id for run in runs) 189 all_ids_in_splits = set() 190 for ids in self.split_contents.values(): 191 all_ids_in_splits.update(ids) 192 missing = all_ids_in_splits - all_ids 193 return len(missing)
Returns: int: the number of task runs that have an ID persisted in this dataset split, but no longer exist in the dataset
218 @staticmethod 219 def compute_tool_info(runs: list[TaskRun]) -> DatasetToolInfo: 220 """ 221 Compute tool info from a list of task runs. 222 223 Args: 224 runs: list of task runs to analyze 225 226 Returns: 227 DatasetToolInfo: information about tools used across the task runs 228 """ 229 230 has_tool_mismatch = False 231 tools: set[str] | None = None 232 233 for run in runs: 234 # Extract tools from run config, treating missing source/run_config/tools_config as empty tools 235 run_tools: set[str] = set() 236 source = run.output.source if run.output else None 237 if source is not None and isinstance( 238 source.run_config, KilnAgentRunConfigProperties 239 ): 240 tools_config = source.run_config.tools_config 241 if tools_config is not None: 242 run_tools = set(tools_config.tools) 243 244 # First run establishes the expected tool set (including empty) 245 if tools is None: 246 tools = run_tools 247 elif run_tools != tools: 248 # Mismatch found 249 has_tool_mismatch = True 250 tools = None 251 break 252 253 # If no valid runs were processed, return empty tools 254 if tools is None: 255 if not has_tool_mismatch: 256 tools = set() 257 258 return DatasetToolInfo( 259 has_tool_mismatch=has_tool_mismatch, 260 tools=None if tools is None else sorted(tools), 261 )
Compute tool info from a list of task runs.
Args: runs: list of task runs to analyze
Returns: DatasetToolInfo: information about tools used across the task runs
263 def tool_info(self) -> DatasetToolInfo: 264 """ 265 Helper method to compute tool info for the dataset split. Iterate through all runs in the dataset split and check the tools used in each run config. 266 267 Returns: 268 DatasetToolInfo: information about tools used across task runs in this dataset split 269 """ 270 runs = self._get_runs() 271 tool_info = self.compute_tool_info(runs) 272 return tool_info
Helper method to compute tool info for the dataset split. Iterate through all runs in the dataset split and check the tools used in each run config.
Returns: DatasetToolInfo: information about tools used across task runs in this dataset split
The type of the None singleton.
Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
365def init_private_attributes(self: BaseModel, context: Any, /) -> None: 366 """This function is meant to behave like a BaseModel method to initialize private attributes. 367 368 It takes context as an argument since that's what pydantic-core passes when calling it. 369 370 Args: 371 self: The BaseModel instance. 372 context: The context. 373 """ 374 if getattr(self, '__pydantic_private__', None) is None: 375 pydantic_private = {} 376 for name, private_attr in self.__private_attributes__.items(): 377 # Avoid needlessly creating a new dict for the validated data: 378 if private_attr.default_factory_takes_validated_data: 379 default = private_attr.get_default( 380 call_default_factory=True, validated_data={**self.__dict__, **pydantic_private} 381 ) 382 else: 383 default = private_attr.get_default(call_default_factory=True) 384 if default is not PydanticUndefined: 385 pydantic_private[name] = default 386 object_setattr(self, '__pydantic_private__', pydantic_private)
This function is meant to behave like a BaseModel method to initialize private attributes.
It takes context as an argument since that's what pydantic-core passes when calling it.
Args: self: The BaseModel instance. context: The context.
39class DatasetSplitDefinition(BaseModel): 40 """ 41 A definition of a split in a dataset. 42 43 Example: name="train", description="The training set", percentage=0.8 (80% of the dataset) 44 """ 45 46 name: FilenameString = Field( 47 description="The name of the dataset split definition." 48 ) 49 description: str | None = Field( 50 default=None, 51 description="A description of the dataset for you and your team. Not used in training.", 52 ) 53 percentage: float = Field( 54 ge=0.0, 55 le=1.0, 56 description="The percentage of the dataset that this split represents (between 0 and 1).", 57 )
A definition of a split in a dataset.
Example: name="train", description="The training set", percentage=0.8 (80% of the dataset)
29class EvalItemSource(BaseModel): 30 """The eval dataset item a TaskRun was generated for. 31 32 Not the run config — that lives on the same TaskRun at 33 `output.source.run_config_id`. 34 """ 35 36 source_type: Literal["eval_input", "task_run"] = Field( 37 description="Which store the dataset item came from: an EvalInput (V2) or a TaskRun (V1-backed split)." 38 ) 39 # `str`, not the usual `ID_TYPE` (`Optional[str]`): an id-less source is a trace-index 40 # key that collides with every other id-less source. Absence is already expressed by 41 # `TaskRun.eval_source` itself being None, so the inner id has no legitimate None 42 # state. `ItemKey`'s own nullability is inherited from `KilnBaseModel.id` rather than 43 # chosen, and a narrower id still satisfies it. 44 source_id: str = Field( 45 min_length=1, 46 description="The id of the dataset item this run was generated for. Interpreted within the store named by source_type — ids are only unique within a store.", 47 )
The eval dataset item a TaskRun was generated for.
Not the run config — that lives on the same TaskRun at
output.source.run_config_id.
64class ExternalToolServer(KilnParentedModel): 65 """ 66 Configuration for communicating with a external MCP (Model Context Protocol) Server for LLM tool calls. External tool servers can be remote or local. 67 68 This model stores the necessary configuration to connect to and authenticate with 69 external MCP servers that provide tools for LLM interactions. 70 """ 71 72 name: FilenameString = Field(description="The name of the external tool.") 73 type: ToolServerType = Field( 74 description="The type of external tool server. Remote tools are hosted on a remote server", 75 ) 76 description: str | None = Field( 77 default=None, 78 description="A description of the external tool for you and your team. Will not be used in prompts/training/validation.", 79 ) 80 81 properties: ( 82 LocalServerProperties | RemoteServerProperties | KilnTaskServerProperties 83 ) = Field( 84 description="Configuration properties specific to the tool type.", 85 ) 86 87 # Private variable to store unsaved secrets 88 _unsaved_secrets: dict[str, str] = PrivateAttr(default_factory=dict) 89 90 def model_post_init(self, __context: Any) -> None: 91 # Process secrets after initialization (pydantic v2 hook) 92 self._process_secrets_from_properties() 93 94 def _process_secrets_from_properties(self) -> None: 95 """ 96 Extract secrets from properties and move them to _unsaved_secrets. 97 This removes secrets from the properties dict so they aren't saved to file. 98 Clears existing _unsaved_secrets first to handle property updates correctly. 99 """ 100 # Clear existing unsaved secrets since we're reprocessing 101 self._unsaved_secrets.clear() 102 103 secret_keys = self.get_secret_keys() 104 105 if not secret_keys: 106 return 107 108 # Extract secret values from properties based on server type 109 match self.type: 110 case ToolServerType.remote_mcp: 111 headers = self.properties.get("headers", {}) 112 for key_name in secret_keys: 113 if key_name in headers: 114 self._unsaved_secrets[key_name] = headers[key_name] 115 # Remove from headers immediately so they are not saved to file 116 del headers[key_name] 117 118 case ToolServerType.local_mcp: 119 env_vars = self.properties.get("env_vars", {}) 120 for key_name in secret_keys: 121 if key_name in env_vars: 122 self._unsaved_secrets[key_name] = env_vars[key_name] 123 # Remove from env_vars immediately so they are not saved to file 124 del env_vars[key_name] 125 126 case ToolServerType.kiln_task: 127 pass 128 129 case _: 130 raise_exhaustive_enum_error(self.type) 131 132 def __setattr__(self, name: str, value: Any) -> None: 133 """ 134 Override __setattr__ to process secrets whenever properties are updated. 135 """ 136 super().__setattr__(name, value) 137 138 # Process secrets whenever properties are updated 139 if name == "properties": 140 self._process_secrets_from_properties() 141 142 # Validation Helpers 143 144 @classmethod 145 def check_server_url(cls, server_url: str) -> None: 146 """Validate Server URL""" 147 if not isinstance(server_url, str): 148 raise ValueError("Server URL must be a string") 149 150 # Check for leading whitespace in URL 151 if server_url != server_url.lstrip(): 152 raise ValueError("Server URL must not have leading whitespace") 153 154 parsed_url = urlparse(server_url) 155 if not parsed_url.netloc: 156 raise ValueError("Server URL is not a valid URL") 157 if parsed_url.scheme not in ["http", "https"]: 158 raise ValueError("Server URL must start with http:// or https://") 159 160 @classmethod 161 def check_headers(cls, headers: dict) -> None: 162 """Validate Headers""" 163 if not isinstance(headers, dict): 164 raise ValueError("headers must be a dictionary") 165 166 for key, value in headers.items(): 167 if not key: 168 raise ValueError("Header name is required") 169 if not value: 170 raise ValueError("Header value is required") 171 172 # Reject invalid header names and CR/LF in names/values 173 token_re = re.compile(r"^[!#$%&'*+.^_`|~0-9A-Za-z-]+$") 174 if not token_re.match(key): 175 raise ValueError(f'Invalid header name: "{key}"') 176 if re.search(r"\r|\n", key) or re.search(r"\r|\n", value): 177 raise ValueError( 178 "Header names/values must not contain invalid characters" 179 ) 180 181 @classmethod 182 def check_secret_keys( 183 cls, secret_keys: list, key_type: str, tool_type: str 184 ) -> None: 185 """Validate Secret Keys (generic method for both header and env var keys)""" 186 if not isinstance(secret_keys, list): 187 raise ValueError( 188 f"{key_type} must be a list for external tools of type '{tool_type}'" 189 ) 190 if not all(isinstance(k, str) for k in secret_keys): 191 raise ValueError(f"{key_type} must contain only strings") 192 if not all(key for key in secret_keys): 193 raise ValueError("Secret key is required") 194 195 @classmethod 196 def check_env_vars(cls, env_vars: dict) -> None: 197 """Validate Environment Variables""" 198 if not isinstance(env_vars, dict): 199 raise ValueError("environment variables must be a dictionary") 200 201 # Validate env_vars keys are in the correct format for Environment Variables 202 # According to POSIX specification, environment variable names must: 203 # - Start with a letter (a-z, A-Z) or underscore (_) 204 # - Contain only ASCII letters, digits, and underscores 205 for key, _ in env_vars.items(): 206 if not key or not ( 207 key[0].isascii() and (key[0].isalpha() or key[0] == "_") 208 ): 209 raise ValueError( 210 f"Invalid environment variable key: {key}. Must start with a letter or underscore." 211 ) 212 213 if not all(c.isascii() and (c.isalnum() or c == "_") for c in key): 214 raise ValueError( 215 f"Invalid environment variable key: {key}. Can only contain letters, digits, and underscores." 216 ) 217 218 @classmethod 219 def type_from_data(cls, data: dict) -> ToolServerType: 220 """Get the tool server type from the data for the the validators""" 221 raw_type = data.get("type") 222 if raw_type is None: 223 raise ValueError("type is required") 224 try: 225 return ToolServerType(raw_type) 226 except ValueError: 227 valid_types = ", ".join(type.value for type in ToolServerType) 228 raise ValueError(f"type must be one of: {valid_types}") 229 230 @model_validator(mode="before") 231 def upgrade_old_properties(cls, data: dict) -> dict: 232 """ 233 Upgrade properties for backwards compatibility. 234 """ 235 properties = data.get("properties") 236 if properties is not None and "is_archived" not in properties: 237 # Add is_archived field with default value back to data 238 properties["is_archived"] = False 239 data["properties"] = properties 240 return data 241 242 @model_validator(mode="before") 243 def validate_required_fields(cls, data: dict) -> dict: 244 """Validate that each tool type has the required configuration.""" 245 server_type = ExternalToolServer.type_from_data(data) 246 properties = data.get("properties", {}) 247 248 match server_type: 249 case ToolServerType.remote_mcp: 250 server_url = properties.get("server_url", None) 251 if server_url is None: 252 raise ValueError( 253 "Server URL is required to connect to a remote MCP server" 254 ) 255 ExternalToolServer.check_server_url(server_url) 256 257 case ToolServerType.local_mcp: 258 command = properties.get("command", None) 259 if command is None: 260 raise ValueError("command is required to start a local MCP server") 261 if not isinstance(command, str): 262 raise ValueError( 263 "command must be a string to start a local MCP server" 264 ) 265 # Reject empty/whitespace-only command strings 266 if command.strip() == "": 267 raise ValueError("command must be a non-empty string") 268 269 args = properties.get("args", None) 270 if args is not None: 271 if not isinstance(args, list): 272 raise ValueError( 273 "arguments must be a list to start a local MCP server" 274 ) 275 276 case ToolServerType.kiln_task: 277 tool_name_validator(properties.get("name", "")) 278 err_msg_prefix = "Kiln task server properties:" 279 validate_return_dict_prop( 280 properties, "description", str, err_msg_prefix 281 ) 282 description = properties.get("description", "") 283 if len(description) > 128: 284 raise ValueError("description must be 128 characters or less") 285 validate_return_dict_prop( 286 properties, "is_archived", bool, err_msg_prefix 287 ) 288 validate_return_dict_prop(properties, "task_id", str, err_msg_prefix) 289 validate_return_dict_prop( 290 properties, "run_config_id", str, err_msg_prefix 291 ) 292 293 case _: 294 # Type checking will catch missing cases 295 raise_exhaustive_enum_error(server_type) 296 return data 297 298 @model_validator(mode="before") 299 def validate_headers_and_env_vars(cls, data: dict) -> dict: 300 """ 301 Validate secrets, these needs to be validated before model initlization because secrets will be processed and stripped 302 """ 303 type = ExternalToolServer.type_from_data(data) 304 305 properties = data.get("properties", {}) 306 if properties is None: 307 raise ValueError("properties is required") 308 309 match type: 310 case ToolServerType.remote_mcp: 311 # Validate headers 312 headers = properties.get("headers", None) 313 if headers is not None: 314 ExternalToolServer.check_headers(headers) 315 316 # Secret header keys are optional, validate if they are set 317 secret_header_keys = properties.get("secret_header_keys", None) 318 if secret_header_keys is not None: 319 ExternalToolServer.check_secret_keys( 320 secret_header_keys, "secret_header_keys", "remote_mcp" 321 ) 322 323 case ToolServerType.local_mcp: 324 # Validate secret environment variable keys 325 env_vars = properties.get("env_vars", {}) 326 if env_vars is not None: 327 ExternalToolServer.check_env_vars(env_vars) 328 329 # Secret env var keys are optional, but if they are set, they must be a list of strings 330 secret_env_var_keys = properties.get("secret_env_var_keys", None) 331 if secret_env_var_keys is not None: 332 ExternalToolServer.check_secret_keys( 333 secret_env_var_keys, "secret_env_var_keys", "local_mcp" 334 ) 335 336 case ToolServerType.kiln_task: 337 pass 338 339 case _: 340 raise_exhaustive_enum_error(type) 341 342 return data 343 344 def get_secret_keys(self) -> list[str]: 345 """ 346 Get the list of secret key names based on server type. 347 348 Returns: 349 List of secret key names (header names for remote, env var names for local) 350 """ 351 match self.type: 352 case ToolServerType.remote_mcp: 353 return self.properties.get("secret_header_keys", []) 354 case ToolServerType.local_mcp: 355 return self.properties.get("secret_env_var_keys", []) 356 case ToolServerType.kiln_task: 357 return [] 358 case _: 359 raise_exhaustive_enum_error(self.type) 360 361 def retrieve_secrets(self) -> tuple[dict[str, str], list[str]]: 362 """ 363 Retrieve secrets from configuration system or in-memory storage. 364 Automatically determines which secret keys to retrieve based on the server type. 365 Config secrets take precedence over unsaved secrets. 366 367 Returns: 368 Tuple of (secrets_dict, missing_secrets_list) where: 369 - secrets_dict: Dictionary mapping key names to their secret values 370 - missing_secrets_list: List of secret key names that are missing values 371 """ 372 secrets = {} 373 missing_secrets = [] 374 secret_keys = self.get_secret_keys() 375 376 if secret_keys and len(secret_keys) > 0: 377 config = Config.shared() 378 mcp_secrets = config.get_value(MCP_SECRETS_KEY) 379 380 for key_name in secret_keys: 381 secret_value = None 382 383 # First check config secrets (persistent storage), key is mcp_server_id::key_name 384 secret_key = self._config_secret_key(key_name) 385 secret_value = mcp_secrets.get(secret_key) if mcp_secrets else None 386 387 # Fall back to unsaved secrets (in-memory storage) 388 if ( 389 not secret_value 390 and hasattr(self, "_unsaved_secrets") 391 and key_name in self._unsaved_secrets 392 ): 393 secret_value = self._unsaved_secrets[key_name] 394 395 if secret_value: 396 secrets[key_name] = secret_value 397 else: 398 missing_secrets.append(key_name) 399 400 return secrets, missing_secrets 401 402 def _save_secrets(self) -> None: 403 """ 404 Save unsaved secrets to the configuration system. 405 """ 406 secret_keys = self.get_secret_keys() 407 408 # No secrets to save 409 if not secret_keys: 410 return 411 412 if self.id is None: 413 raise ValueError("Server ID cannot be None when saving secrets") 414 415 # Check if secrets are already saved 416 if not hasattr(self, "_unsaved_secrets") or not self._unsaved_secrets: 417 return 418 419 config = Config.shared() 420 mcp_secrets: dict[str, str] = config.get_value(MCP_SECRETS_KEY) or {} 421 422 # Store secrets with the pattern: mcp_server_id::key_name 423 for key_name, secret_value in self._unsaved_secrets.items(): 424 secret_key = self._config_secret_key(key_name) 425 mcp_secrets[secret_key] = secret_value 426 427 config.update_settings({MCP_SECRETS_KEY: mcp_secrets}) 428 429 # Clear unsaved secrets after saving 430 self._unsaved_secrets.clear() 431 432 def delete_secrets(self) -> None: 433 """ 434 Delete all secrets for this tool server from the configuration system. 435 """ 436 secret_keys = self.get_secret_keys() 437 438 config = Config.shared() 439 mcp_secrets = config.get_value(MCP_SECRETS_KEY) or dict[str, str]() 440 441 # Remove secrets with the pattern: mcp_server_id::key_name 442 for key_name in secret_keys: 443 secret_key = self._config_secret_key(key_name) 444 if secret_key in mcp_secrets: 445 del mcp_secrets[secret_key] 446 447 # Always call update_settings to maintain consistency with the old behavior 448 config.update_settings({MCP_SECRETS_KEY: mcp_secrets}) 449 450 def save_to_file(self) -> None: 451 """ 452 Override save_to_file to automatically save any unsaved secrets before saving to file. 453 454 This ensures that secrets are always saved when the object is saved, 455 preventing the issue where secrets could be lost if save_to_file is called 456 without explicitly saving secrets first. 457 """ 458 # Save any unsaved secrets first 459 if hasattr(self, "_unsaved_secrets") and self._unsaved_secrets: 460 self._save_secrets() 461 462 # Call the parent save_to_file method 463 super().save_to_file() 464 465 # Internal helpers 466 467 def _config_secret_key(self, key_name: str) -> str: 468 """ 469 Generate the secret key pattern for storing/retrieving secrets. 470 471 Args: 472 key_name: The name of the secret key 473 474 Returns: 475 The formatted secret key: "{server_id}::{key_name}" 476 """ 477 return f"{self.id}::{key_name}"
Configuration for communicating with a external MCP (Model Context Protocol) Server for LLM tool calls. External tool servers can be remote or local.
This model stores the necessary configuration to connect to and authenticate with external MCP servers that provide tools for LLM interactions.
90 def model_post_init(self, __context: Any) -> None: 91 # Process secrets after initialization (pydantic v2 hook) 92 self._process_secrets_from_properties()
This function is meant to behave like a BaseModel method to initialize private attributes.
It takes context as an argument since that's what pydantic-core passes when calling it.
Args: self: The BaseModel instance. context: The context.
144 @classmethod 145 def check_server_url(cls, server_url: str) -> None: 146 """Validate Server URL""" 147 if not isinstance(server_url, str): 148 raise ValueError("Server URL must be a string") 149 150 # Check for leading whitespace in URL 151 if server_url != server_url.lstrip(): 152 raise ValueError("Server URL must not have leading whitespace") 153 154 parsed_url = urlparse(server_url) 155 if not parsed_url.netloc: 156 raise ValueError("Server URL is not a valid URL") 157 if parsed_url.scheme not in ["http", "https"]: 158 raise ValueError("Server URL must start with http:// or https://")
Validate Server URL
160 @classmethod 161 def check_headers(cls, headers: dict) -> None: 162 """Validate Headers""" 163 if not isinstance(headers, dict): 164 raise ValueError("headers must be a dictionary") 165 166 for key, value in headers.items(): 167 if not key: 168 raise ValueError("Header name is required") 169 if not value: 170 raise ValueError("Header value is required") 171 172 # Reject invalid header names and CR/LF in names/values 173 token_re = re.compile(r"^[!#$%&'*+.^_`|~0-9A-Za-z-]+$") 174 if not token_re.match(key): 175 raise ValueError(f'Invalid header name: "{key}"') 176 if re.search(r"\r|\n", key) or re.search(r"\r|\n", value): 177 raise ValueError( 178 "Header names/values must not contain invalid characters" 179 )
Validate Headers
181 @classmethod 182 def check_secret_keys( 183 cls, secret_keys: list, key_type: str, tool_type: str 184 ) -> None: 185 """Validate Secret Keys (generic method for both header and env var keys)""" 186 if not isinstance(secret_keys, list): 187 raise ValueError( 188 f"{key_type} must be a list for external tools of type '{tool_type}'" 189 ) 190 if not all(isinstance(k, str) for k in secret_keys): 191 raise ValueError(f"{key_type} must contain only strings") 192 if not all(key for key in secret_keys): 193 raise ValueError("Secret key is required")
Validate Secret Keys (generic method for both header and env var keys)
195 @classmethod 196 def check_env_vars(cls, env_vars: dict) -> None: 197 """Validate Environment Variables""" 198 if not isinstance(env_vars, dict): 199 raise ValueError("environment variables must be a dictionary") 200 201 # Validate env_vars keys are in the correct format for Environment Variables 202 # According to POSIX specification, environment variable names must: 203 # - Start with a letter (a-z, A-Z) or underscore (_) 204 # - Contain only ASCII letters, digits, and underscores 205 for key, _ in env_vars.items(): 206 if not key or not ( 207 key[0].isascii() and (key[0].isalpha() or key[0] == "_") 208 ): 209 raise ValueError( 210 f"Invalid environment variable key: {key}. Must start with a letter or underscore." 211 ) 212 213 if not all(c.isascii() and (c.isalnum() or c == "_") for c in key): 214 raise ValueError( 215 f"Invalid environment variable key: {key}. Can only contain letters, digits, and underscores." 216 )
Validate Environment Variables
218 @classmethod 219 def type_from_data(cls, data: dict) -> ToolServerType: 220 """Get the tool server type from the data for the the validators""" 221 raw_type = data.get("type") 222 if raw_type is None: 223 raise ValueError("type is required") 224 try: 225 return ToolServerType(raw_type) 226 except ValueError: 227 valid_types = ", ".join(type.value for type in ToolServerType) 228 raise ValueError(f"type must be one of: {valid_types}")
Get the tool server type from the data for the the validators
230 @model_validator(mode="before") 231 def upgrade_old_properties(cls, data: dict) -> dict: 232 """ 233 Upgrade properties for backwards compatibility. 234 """ 235 properties = data.get("properties") 236 if properties is not None and "is_archived" not in properties: 237 # Add is_archived field with default value back to data 238 properties["is_archived"] = False 239 data["properties"] = properties 240 return data
Upgrade properties for backwards compatibility.
242 @model_validator(mode="before") 243 def validate_required_fields(cls, data: dict) -> dict: 244 """Validate that each tool type has the required configuration.""" 245 server_type = ExternalToolServer.type_from_data(data) 246 properties = data.get("properties", {}) 247 248 match server_type: 249 case ToolServerType.remote_mcp: 250 server_url = properties.get("server_url", None) 251 if server_url is None: 252 raise ValueError( 253 "Server URL is required to connect to a remote MCP server" 254 ) 255 ExternalToolServer.check_server_url(server_url) 256 257 case ToolServerType.local_mcp: 258 command = properties.get("command", None) 259 if command is None: 260 raise ValueError("command is required to start a local MCP server") 261 if not isinstance(command, str): 262 raise ValueError( 263 "command must be a string to start a local MCP server" 264 ) 265 # Reject empty/whitespace-only command strings 266 if command.strip() == "": 267 raise ValueError("command must be a non-empty string") 268 269 args = properties.get("args", None) 270 if args is not None: 271 if not isinstance(args, list): 272 raise ValueError( 273 "arguments must be a list to start a local MCP server" 274 ) 275 276 case ToolServerType.kiln_task: 277 tool_name_validator(properties.get("name", "")) 278 err_msg_prefix = "Kiln task server properties:" 279 validate_return_dict_prop( 280 properties, "description", str, err_msg_prefix 281 ) 282 description = properties.get("description", "") 283 if len(description) > 128: 284 raise ValueError("description must be 128 characters or less") 285 validate_return_dict_prop( 286 properties, "is_archived", bool, err_msg_prefix 287 ) 288 validate_return_dict_prop(properties, "task_id", str, err_msg_prefix) 289 validate_return_dict_prop( 290 properties, "run_config_id", str, err_msg_prefix 291 ) 292 293 case _: 294 # Type checking will catch missing cases 295 raise_exhaustive_enum_error(server_type) 296 return data
Validate that each tool type has the required configuration.
298 @model_validator(mode="before") 299 def validate_headers_and_env_vars(cls, data: dict) -> dict: 300 """ 301 Validate secrets, these needs to be validated before model initlization because secrets will be processed and stripped 302 """ 303 type = ExternalToolServer.type_from_data(data) 304 305 properties = data.get("properties", {}) 306 if properties is None: 307 raise ValueError("properties is required") 308 309 match type: 310 case ToolServerType.remote_mcp: 311 # Validate headers 312 headers = properties.get("headers", None) 313 if headers is not None: 314 ExternalToolServer.check_headers(headers) 315 316 # Secret header keys are optional, validate if they are set 317 secret_header_keys = properties.get("secret_header_keys", None) 318 if secret_header_keys is not None: 319 ExternalToolServer.check_secret_keys( 320 secret_header_keys, "secret_header_keys", "remote_mcp" 321 ) 322 323 case ToolServerType.local_mcp: 324 # Validate secret environment variable keys 325 env_vars = properties.get("env_vars", {}) 326 if env_vars is not None: 327 ExternalToolServer.check_env_vars(env_vars) 328 329 # Secret env var keys are optional, but if they are set, they must be a list of strings 330 secret_env_var_keys = properties.get("secret_env_var_keys", None) 331 if secret_env_var_keys is not None: 332 ExternalToolServer.check_secret_keys( 333 secret_env_var_keys, "secret_env_var_keys", "local_mcp" 334 ) 335 336 case ToolServerType.kiln_task: 337 pass 338 339 case _: 340 raise_exhaustive_enum_error(type) 341 342 return data
Validate secrets, these needs to be validated before model initlization because secrets will be processed and stripped
344 def get_secret_keys(self) -> list[str]: 345 """ 346 Get the list of secret key names based on server type. 347 348 Returns: 349 List of secret key names (header names for remote, env var names for local) 350 """ 351 match self.type: 352 case ToolServerType.remote_mcp: 353 return self.properties.get("secret_header_keys", []) 354 case ToolServerType.local_mcp: 355 return self.properties.get("secret_env_var_keys", []) 356 case ToolServerType.kiln_task: 357 return [] 358 case _: 359 raise_exhaustive_enum_error(self.type)
Get the list of secret key names based on server type.
Returns: List of secret key names (header names for remote, env var names for local)
361 def retrieve_secrets(self) -> tuple[dict[str, str], list[str]]: 362 """ 363 Retrieve secrets from configuration system or in-memory storage. 364 Automatically determines which secret keys to retrieve based on the server type. 365 Config secrets take precedence over unsaved secrets. 366 367 Returns: 368 Tuple of (secrets_dict, missing_secrets_list) where: 369 - secrets_dict: Dictionary mapping key names to their secret values 370 - missing_secrets_list: List of secret key names that are missing values 371 """ 372 secrets = {} 373 missing_secrets = [] 374 secret_keys = self.get_secret_keys() 375 376 if secret_keys and len(secret_keys) > 0: 377 config = Config.shared() 378 mcp_secrets = config.get_value(MCP_SECRETS_KEY) 379 380 for key_name in secret_keys: 381 secret_value = None 382 383 # First check config secrets (persistent storage), key is mcp_server_id::key_name 384 secret_key = self._config_secret_key(key_name) 385 secret_value = mcp_secrets.get(secret_key) if mcp_secrets else None 386 387 # Fall back to unsaved secrets (in-memory storage) 388 if ( 389 not secret_value 390 and hasattr(self, "_unsaved_secrets") 391 and key_name in self._unsaved_secrets 392 ): 393 secret_value = self._unsaved_secrets[key_name] 394 395 if secret_value: 396 secrets[key_name] = secret_value 397 else: 398 missing_secrets.append(key_name) 399 400 return secrets, missing_secrets
Retrieve secrets from configuration system or in-memory storage. Automatically determines which secret keys to retrieve based on the server type. Config secrets take precedence over unsaved secrets.
Returns: Tuple of (secrets_dict, missing_secrets_list) where: - secrets_dict: Dictionary mapping key names to their secret values - missing_secrets_list: List of secret key names that are missing values
432 def delete_secrets(self) -> None: 433 """ 434 Delete all secrets for this tool server from the configuration system. 435 """ 436 secret_keys = self.get_secret_keys() 437 438 config = Config.shared() 439 mcp_secrets = config.get_value(MCP_SECRETS_KEY) or dict[str, str]() 440 441 # Remove secrets with the pattern: mcp_server_id::key_name 442 for key_name in secret_keys: 443 secret_key = self._config_secret_key(key_name) 444 if secret_key in mcp_secrets: 445 del mcp_secrets[secret_key] 446 447 # Always call update_settings to maintain consistency with the old behavior 448 config.update_settings({MCP_SECRETS_KEY: mcp_secrets})
Delete all secrets for this tool server from the configuration system.
450 def save_to_file(self) -> None: 451 """ 452 Override save_to_file to automatically save any unsaved secrets before saving to file. 453 454 This ensures that secrets are always saved when the object is saved, 455 preventing the issue where secrets could be lost if save_to_file is called 456 without explicitly saving secrets first. 457 """ 458 # Save any unsaved secrets first 459 if hasattr(self, "_unsaved_secrets") and self._unsaved_secrets: 460 self._save_secrets() 461 462 # Call the parent save_to_file method 463 super().save_to_file()
Override save_to_file to automatically save any unsaved secrets before saving to file.
This ensures that secrets are always saved when the object is saved, preventing the issue where secrets could be lost if save_to_file is called without explicitly saving secrets first.
8class Feedback(KilnParentedModel): 9 """Feedback on a task run. 10 11 Supports multi-source feedback: different users, automated systems, and 12 different locations in the UI can each contribute independent feedback 13 entries on the same task run. 14 """ 15 16 feedback: str = Field( 17 min_length=1, 18 description="Free-form text feedback on the task run.", 19 ) 20 source: FeedbackSource = Field( 21 description="Where this feedback originated, e.g. 'run-page' or 'spec-feedback'.", 22 )
Feedback on a task run.
Supports multi-source feedback: different users, automated systems, and different locations in the UI can each contribute independent feedback entries on the same task run.
The type of the None singleton.
Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
365def init_private_attributes(self: BaseModel, context: Any, /) -> None: 366 """This function is meant to behave like a BaseModel method to initialize private attributes. 367 368 It takes context as an argument since that's what pydantic-core passes when calling it. 369 370 Args: 371 self: The BaseModel instance. 372 context: The context. 373 """ 374 if getattr(self, '__pydantic_private__', None) is None: 375 pydantic_private = {} 376 for name, private_attr in self.__private_attributes__.items(): 377 # Avoid needlessly creating a new dict for the validated data: 378 if private_attr.default_factory_takes_validated_data: 379 default = private_attr.get_default( 380 call_default_factory=True, validated_data={**self.__dict__, **pydantic_private} 381 ) 382 else: 383 default = private_attr.get_default(call_default_factory=True) 384 if default is not PydanticUndefined: 385 pydantic_private[name] = default 386 object_setattr(self, '__pydantic_private__', pydantic_private)
This function is meant to behave like a BaseModel method to initialize private attributes.
It takes context as an argument since that's what pydantic-core passes when calling it.
Args: self: The BaseModel instance. context: The context.
94class FeedbackSource(str, Enum): 95 """Where a piece of feedback originated. 96 97 This is an append-only enum: new sources can be added freely, but existing 98 values must never be removed or renamed so that older persisted data 99 continues to load. 100 """ 101 102 run_page = "run-page" 103 spec_feedback = "spec-feedback"
Where a piece of feedback originated.
This is an append-only enum: new sources can be added freely, but existing values must never be removed or renamed so that older persisted data continues to load.
62class FineTuneStatusType(str, Enum): 63 """ 64 The status type of a fine-tune job. 65 """ 66 67 unknown = "unknown" 68 pending = "pending" 69 running = "running" 70 completed = "completed" 71 failed = "failed"
The status type of a fine-tune job.
24class Finetune(KilnParentedModel): 25 """ 26 The Kiln fine-tune datamodel. 27 28 Initially holds a reference to a training job, with needed identifiers to update the status. When complete, contains the new model ID. 29 """ 30 31 name: FilenameString = Field(description="The name of the fine-tune.") 32 description: str | None = Field( 33 default=None, 34 description="A description of the fine-tune for you and your team. Not used in training.", 35 ) 36 structured_output_mode: StructuredOutputMode | None = Field( 37 default=None, 38 description="Legacy field -- replaced by run_config.structured_output_mode. The mode to use to train the model for structured output, if it was trained with structured output. We should call the tuned model with this mode if set.", 39 ) 40 provider: str = Field( 41 description="The provider to use for the fine-tune (e.g. 'openai')." 42 ) 43 base_model_id: str = Field( 44 description="The id of the base model to use for the fine-tune. This string relates to the provider's IDs for their own models, not Kiln IDs." 45 ) 46 provider_id: str | None = Field( 47 default=None, 48 description="The ID of the fine-tune job on the provider's side. May not be the same as the fine_tune_model_id.", 49 ) 50 fine_tune_model_id: str | None = Field( 51 default=None, 52 description="The ID of the fine-tuned model on the provider's side. May not be the same as the provider_id.", 53 ) 54 dataset_split_id: str = Field( 55 description="The ID of the dataset split to use for this fine-tune.", 56 ) 57 train_split_name: str = Field( 58 default="train", 59 description="The name of the training split to use for this fine-tune.", 60 ) 61 validation_split_name: str | None = Field( 62 default=None, 63 description="The name of the validation split to use for this fine-tune. Optional.", 64 ) 65 parameters: dict[str, str | int | float | bool] = Field( 66 default={}, 67 description="The parameters to use for this fine-tune. These are provider-specific.", 68 ) 69 # These two fields are saved exactly used for training. Even if they map exactly to a custom prompt or generator, those can change, so we want to keep a record of the training prompt. 70 system_message: str = Field( 71 description="The system message to use for this fine-tune.", 72 ) 73 thinking_instructions: str | None = Field( 74 default=None, 75 description="The thinking instructions to use for this fine-tune. Only used when data_strategy is final_and_intermediate.", 76 ) 77 latest_status: FineTuneStatusType = Field( 78 default=FineTuneStatusType.unknown, 79 description="The latest known status of this fine-tune. Not updated in real time.", 80 ) 81 properties: Dict[str, str | int | float] = Field( 82 default={}, 83 description="Properties of the fine-tune. Different providers may use different properties.", 84 ) 85 data_strategy: ChatStrategy = Field( 86 default=ChatStrategy.single_turn, 87 description="The strategy to use for training the model. 'final_only' will only train on the final response. 'final_and_intermediate' will train on the final response and intermediate outputs (chain of thought or reasoning).", 88 ) 89 run_config: KilnAgentRunConfigProperties | None = Field( 90 default=None, 91 description="The run configuration for this fine-tune.", 92 ) 93 94 # Workaround to return typed parent without importing Task 95 def parent_task(self) -> Union["Task", None]: 96 if self.parent is None or self.parent.__class__.__name__ != "Task": 97 return None 98 return self.parent # type: ignore 99 100 def nested_id(self) -> str: 101 """ 102 Build the nested ID for this finetune in the format: project_id::task_id::finetune_id 103 """ 104 task = self.parent_task() 105 if task is None: 106 raise ValueError("Finetune must have a parent task") 107 project = task.parent_project() 108 if project is None: 109 raise ValueError("Finetune must have a parent project") 110 return f"{project.id}::{task.id}::{self.id}" 111 112 @model_validator(mode="after") 113 def validate_thinking_instructions(self) -> Self: 114 if ( 115 self.thinking_instructions is not None 116 and self.data_strategy not in DATA_STRATEGIES_REQUIRED_THINKING_INSTRUCTIONS 117 ): 118 raise ValueError( 119 f"Thinking instructions can only be used when data_strategy is one of the following: {DATA_STRATEGIES_REQUIRED_THINKING_INSTRUCTIONS}" 120 ) 121 if ( 122 self.thinking_instructions is None 123 and self.data_strategy in DATA_STRATEGIES_REQUIRED_THINKING_INSTRUCTIONS 124 ): 125 raise ValueError( 126 f"Thinking instructions are required when data_strategy is one of the following: {DATA_STRATEGIES_REQUIRED_THINKING_INSTRUCTIONS}" 127 ) 128 return self
The Kiln fine-tune datamodel.
Initially holds a reference to a training job, with needed identifiers to update the status. When complete, contains the new model ID.
100 def nested_id(self) -> str: 101 """ 102 Build the nested ID for this finetune in the format: project_id::task_id::finetune_id 103 """ 104 task = self.parent_task() 105 if task is None: 106 raise ValueError("Finetune must have a parent task") 107 project = task.parent_project() 108 if project is None: 109 raise ValueError("Finetune must have a parent project") 110 return f"{project.id}::{task.id}::{self.id}"
Build the nested ID for this finetune in the format: project_id::task_id::finetune_id
112 @model_validator(mode="after") 113 def validate_thinking_instructions(self) -> Self: 114 if ( 115 self.thinking_instructions is not None 116 and self.data_strategy not in DATA_STRATEGIES_REQUIRED_THINKING_INSTRUCTIONS 117 ): 118 raise ValueError( 119 f"Thinking instructions can only be used when data_strategy is one of the following: {DATA_STRATEGIES_REQUIRED_THINKING_INSTRUCTIONS}" 120 ) 121 if ( 122 self.thinking_instructions is None 123 and self.data_strategy in DATA_STRATEGIES_REQUIRED_THINKING_INSTRUCTIONS 124 ): 125 raise ValueError( 126 f"Thinking instructions are required when data_strategy is one of the following: {DATA_STRATEGIES_REQUIRED_THINKING_INSTRUCTIONS}" 127 ) 128 return self
The type of the None singleton.
Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
365def init_private_attributes(self: BaseModel, context: Any, /) -> None: 366 """This function is meant to behave like a BaseModel method to initialize private attributes. 367 368 It takes context as an argument since that's what pydantic-core passes when calling it. 369 370 Args: 371 self: The BaseModel instance. 372 context: The context. 373 """ 374 if getattr(self, '__pydantic_private__', None) is None: 375 pydantic_private = {} 376 for name, private_attr in self.__private_attributes__.items(): 377 # Avoid needlessly creating a new dict for the validated data: 378 if private_attr.default_factory_takes_validated_data: 379 default = private_attr.get_default( 380 call_default_factory=True, validated_data={**self.__dict__, **pydantic_private} 381 ) 382 else: 383 default = private_attr.get_default(call_default_factory=True) 384 if default is not PydanticUndefined: 385 pydantic_private[name] = default 386 object_setattr(self, '__pydantic_private__', pydantic_private)
This function is meant to behave like a BaseModel method to initialize private attributes.
It takes context as an argument since that's what pydantic-core passes when calling it.
Args: self: The BaseModel instance. context: The context.
57class MessageUsage(BaseModel): 58 """Token usage and cost for a single LLM call or a multi-message sum. 59 60 Carries only the fields that are meaningfully aggregatable across 61 messages: token counts and cost. Per-call latency lives on the 62 individual message's ``latency_ms`` field; aggregating it across the 63 full trace would mix latencies from different points in time, so 64 ``MessageUsage`` does NOT carry ``total_llm_latency_ms``. 65 66 The :class:`Usage` subclass adds ``total_llm_latency_ms`` for the 67 in-flight per-run accumulator that tracks how long this run spent 68 waiting on LLM calls. 69 """ 70 71 input_tokens: int | None = Field( 72 default=None, 73 description="The number of input tokens used.", 74 ge=0, 75 ) 76 output_tokens: int | None = Field( 77 default=None, 78 description="The number of output tokens used.", 79 ge=0, 80 ) 81 total_tokens: int | None = Field( 82 default=None, 83 description="The total number of tokens used.", 84 ge=0, 85 ) 86 cost: float | None = Field( 87 default=None, 88 description="The cost in US dollars, saved at runtime (prices can change over time).", 89 ge=0, 90 ) 91 cached_tokens: int | None = Field( 92 default=None, 93 description="Number of tokens served from prompt cache. None if not reported.", 94 ge=0, 95 ) 96 97 def __add__(self, other: "MessageUsage") -> "MessageUsage": 98 """Add two MessageUsage objects together, handling None values gracefully. 99 100 None + None = None 101 None + value = value 102 value + None = value 103 value1 + value2 = value1 + value2 104 """ 105 if not isinstance(other, MessageUsage): 106 raise TypeError(f"Cannot add MessageUsage with {type(other).__name__}") 107 108 return MessageUsage( 109 input_tokens=_add_optional_int(self.input_tokens, other.input_tokens), 110 output_tokens=_add_optional_int(self.output_tokens, other.output_tokens), 111 total_tokens=_add_optional_int(self.total_tokens, other.total_tokens), 112 cost=_add_optional_float(self.cost, other.cost), 113 cached_tokens=_add_optional_int(self.cached_tokens, other.cached_tokens), 114 ) 115 116 @staticmethod 117 def from_trace( 118 trace: "list[ChatCompletionMessageParam] | None", 119 ) -> "MessageUsage": 120 """Sum per-message usage across all assistant messages in a trace. 121 122 Returns MessageUsage() (all fields None) when trace is None/empty 123 or no assistant message has a `usage` field. Skips non-assistant 124 messages and messages where `usage` is missing or None. Always 125 returns a MessageUsage instance — never None. 126 127 Accepts per-message `usage` values that are either MessageUsage 128 instances (including the Usage subclass) or plain dicts (e.g. 129 from JSON round-trips); dicts are validated to MessageUsage 130 before summing. 131 """ 132 total: MessageUsage = MessageUsage() 133 if not trace: 134 return total 135 136 for message in trace: 137 if not isinstance(message, dict): 138 continue 139 if message.get("role") != "assistant": 140 continue 141 raw_usage = message.get("usage") 142 if raw_usage is None: 143 continue 144 if isinstance(raw_usage, MessageUsage): 145 total = total + raw_usage 146 elif isinstance(raw_usage, dict): 147 total = total + MessageUsage.model_validate(raw_usage) 148 return total
Token usage and cost for a single LLM call or a multi-message sum.
Carries only the fields that are meaningfully aggregatable across
messages: token counts and cost. Per-call latency lives on the
individual message's latency_ms field; aggregating it across the
full trace would mix latencies from different points in time, so
MessageUsage does NOT carry total_llm_latency_ms.
The Usage subclass adds total_llm_latency_ms for the
in-flight per-run accumulator that tracks how long this run spent
waiting on LLM calls.
116 @staticmethod 117 def from_trace( 118 trace: "list[ChatCompletionMessageParam] | None", 119 ) -> "MessageUsage": 120 """Sum per-message usage across all assistant messages in a trace. 121 122 Returns MessageUsage() (all fields None) when trace is None/empty 123 or no assistant message has a `usage` field. Skips non-assistant 124 messages and messages where `usage` is missing or None. Always 125 returns a MessageUsage instance — never None. 126 127 Accepts per-message `usage` values that are either MessageUsage 128 instances (including the Usage subclass) or plain dicts (e.g. 129 from JSON round-trips); dicts are validated to MessageUsage 130 before summing. 131 """ 132 total: MessageUsage = MessageUsage() 133 if not trace: 134 return total 135 136 for message in trace: 137 if not isinstance(message, dict): 138 continue 139 if message.get("role") != "assistant": 140 continue 141 raw_usage = message.get("usage") 142 if raw_usage is None: 143 continue 144 if isinstance(raw_usage, MessageUsage): 145 total = total + raw_usage 146 elif isinstance(raw_usage, dict): 147 total = total + MessageUsage.model_validate(raw_usage) 148 return total
Sum per-message usage across all assistant messages in a trace.
Returns MessageUsage() (all fields None) when trace is None/empty
or no assistant message has a usage field. Skips non-assistant
messages and messages where usage is missing or None. Always
returns a MessageUsage instance — never None.
Accepts per-message usage values that are either MessageUsage
instances (including the Usage subclass) or plain dicts (e.g.
from JSON round-trips); dicts are validated to MessageUsage
before summing.
9class Priority(IntEnum): 10 """Priority levels, where P0 is highest priority.""" 11 12 p0 = 0 13 p1 = 1 14 p2 = 2 15 p3 = 3
Priority levels, where P0 is highest priority.
17class Project( 18 KilnParentModel, 19 parent_of={ 20 "tasks": Task, 21 "documents": Document, 22 "extractor_configs": ExtractorConfig, 23 "chunker_configs": ChunkerConfig, 24 "embedding_configs": EmbeddingConfig, 25 "rag_configs": RagConfig, 26 "vector_store_configs": VectorStoreConfig, 27 "external_tool_servers": ExternalToolServer, 28 "reranker_configs": RerankerConfig, 29 "skills": Skill, 30 "code_tools": CodeTool, 31 }, 32): 33 """ 34 A collection of related tasks. 35 36 Projects organize tasks into logical groups and provide high-level descriptions 37 of the overall goals. 38 """ 39 40 name: FilenameString = Field(description="The name of the project.") 41 description: str | None = Field( 42 default=None, 43 description="A description of the project for you and your team. Will not be used in prompts/training/validation.", 44 ) 45 46 # Needed for typechecking. We should fix this in KilnParentModel 47 def tasks(self, readonly: bool = False) -> list[Task]: 48 return super().tasks(readonly=readonly) # type: ignore 49 50 def documents(self, readonly: bool = False) -> list[Document]: 51 return super().documents(readonly=readonly) # type: ignore 52 53 def extractor_configs(self, readonly: bool = False) -> list[ExtractorConfig]: 54 return super().extractor_configs(readonly=readonly) # type: ignore 55 56 def chunker_configs(self, readonly: bool = False) -> list[ChunkerConfig]: 57 return super().chunker_configs(readonly=readonly) # type: ignore 58 59 def embedding_configs(self, readonly: bool = False) -> list[EmbeddingConfig]: 60 return super().embedding_configs(readonly=readonly) # type: ignore 61 62 def vector_store_configs(self, readonly: bool = False) -> list[VectorStoreConfig]: 63 return super().vector_store_configs(readonly=readonly) # type: ignore 64 65 def rag_configs(self, readonly: bool = False) -> list[RagConfig]: 66 return super().rag_configs(readonly=readonly) # type: ignore 67 68 def external_tool_servers(self, readonly: bool = False) -> list[ExternalToolServer]: 69 return super().external_tool_servers(readonly=readonly) # type: ignore 70 71 def reranker_configs(self, readonly: bool = False) -> list[RerankerConfig]: 72 return super().reranker_configs(readonly=readonly) # type: ignore 73 74 def skills(self, readonly: bool = False) -> list[Skill]: 75 return super().skills(readonly=readonly) # type: ignore 76 77 def code_tools(self, readonly: bool = False) -> list[CodeTool]: 78 return super().code_tools(readonly=readonly) # type: ignore
A collection of related tasks.
Projects organize tasks into logical groups and provide high-level descriptions of the overall goals.
838 def child_method(self, readonly: bool = False) -> list[child_class]: # type: ignore[invalid-type-form] 839 return child_class.all_children_of_parent_path(self.path, readonly=readonly)
The type of the None singleton.
838 def child_method(self, readonly: bool = False) -> list[child_class]: # type: ignore[invalid-type-form] 839 return child_class.all_children_of_parent_path(self.path, readonly=readonly)
The type of the None singleton.
838 def child_method(self, readonly: bool = False) -> list[child_class]: # type: ignore[invalid-type-form] 839 return child_class.all_children_of_parent_path(self.path, readonly=readonly)
The type of the None singleton.
838 def child_method(self, readonly: bool = False) -> list[child_class]: # type: ignore[invalid-type-form] 839 return child_class.all_children_of_parent_path(self.path, readonly=readonly)
The type of the None singleton.
838 def child_method(self, readonly: bool = False) -> list[child_class]: # type: ignore[invalid-type-form] 839 return child_class.all_children_of_parent_path(self.path, readonly=readonly)
The type of the None singleton.
838 def child_method(self, readonly: bool = False) -> list[child_class]: # type: ignore[invalid-type-form] 839 return child_class.all_children_of_parent_path(self.path, readonly=readonly)
The type of the None singleton.
838 def child_method(self, readonly: bool = False) -> list[child_class]: # type: ignore[invalid-type-form] 839 return child_class.all_children_of_parent_path(self.path, readonly=readonly)
The type of the None singleton.
838 def child_method(self, readonly: bool = False) -> list[child_class]: # type: ignore[invalid-type-form] 839 return child_class.all_children_of_parent_path(self.path, readonly=readonly)
The type of the None singleton.
838 def child_method(self, readonly: bool = False) -> list[child_class]: # type: ignore[invalid-type-form] 839 return child_class.all_children_of_parent_path(self.path, readonly=readonly)
The type of the None singleton.
838 def child_method(self, readonly: bool = False) -> list[child_class]: # type: ignore[invalid-type-form] 839 return child_class.all_children_of_parent_path(self.path, readonly=readonly)
The type of the None singleton.
838 def child_method(self, readonly: bool = False) -> list[child_class]: # type: ignore[invalid-type-form] 839 return child_class.all_children_of_parent_path(self.path, readonly=readonly)
The type of the None singleton.
Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
365def init_private_attributes(self: BaseModel, context: Any, /) -> None: 366 """This function is meant to behave like a BaseModel method to initialize private attributes. 367 368 It takes context as an argument since that's what pydantic-core passes when calling it. 369 370 Args: 371 self: The BaseModel instance. 372 context: The context. 373 """ 374 if getattr(self, '__pydantic_private__', None) is None: 375 pydantic_private = {} 376 for name, private_attr in self.__private_attributes__.items(): 377 # Avoid needlessly creating a new dict for the validated data: 378 if private_attr.default_factory_takes_validated_data: 379 default = private_attr.get_default( 380 call_default_factory=True, validated_data={**self.__dict__, **pydantic_private} 381 ) 382 else: 383 default = private_attr.get_default(call_default_factory=True) 384 if default is not PydanticUndefined: 385 pydantic_private[name] = default 386 object_setattr(self, '__pydantic_private__', pydantic_private)
This function is meant to behave like a BaseModel method to initialize private attributes.
It takes context as an argument since that's what pydantic-core passes when calling it.
Args: self: The BaseModel instance. context: The context.
33class Prompt(KilnParentedModel, BasePrompt): 34 """ 35 A prompt for a task. This is the custom prompt parented by a task. 36 """ 37 38 pass
A prompt for a task. This is the custom prompt parented by a task.
The type of the None singleton.
Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
365def init_private_attributes(self: BaseModel, context: Any, /) -> None: 366 """This function is meant to behave like a BaseModel method to initialize private attributes. 367 368 It takes context as an argument since that's what pydantic-core passes when calling it. 369 370 Args: 371 self: The BaseModel instance. 372 context: The context. 373 """ 374 if getattr(self, '__pydantic_private__', None) is None: 375 pydantic_private = {} 376 for name, private_attr in self.__private_attributes__.items(): 377 # Avoid needlessly creating a new dict for the validated data: 378 if private_attr.default_factory_takes_validated_data: 379 default = private_attr.get_default( 380 call_default_factory=True, validated_data={**self.__dict__, **pydantic_private} 381 ) 382 else: 383 default = private_attr.get_default(call_default_factory=True) 384 if default is not PydanticUndefined: 385 pydantic_private[name] = default 386 object_setattr(self, '__pydantic_private__', pydantic_private)
This function is meant to behave like a BaseModel method to initialize private attributes.
It takes context as an argument since that's what pydantic-core passes when calling it.
Args: self: The BaseModel instance. context: The context.
9class PromptGenerators(str, Enum): 10 """Built-in prompt generators that can construct a prompt from a task definition.""" 11 12 SIMPLE = "simple_prompt_builder" 13 MULTI_SHOT = "multi_shot_prompt_builder" 14 FEW_SHOT = "few_shot_prompt_builder" 15 REPAIRS = "repairs_prompt_builder" 16 SIMPLE_CHAIN_OF_THOUGHT = "simple_chain_of_thought_prompt_builder" 17 FEW_SHOT_CHAIN_OF_THOUGHT = "few_shot_chain_of_thought_prompt_builder" 18 MULTI_SHOT_CHAIN_OF_THOUGHT = "multi_shot_chain_of_thought_prompt_builder"
Built-in prompt generators that can construct a prompt from a task definition.
12class PromptOptimizationJob(KilnParentedModel): 13 """ 14 The Kiln prompt optimization job datamodel. 15 """ 16 17 name: FilenameString = Field(description="The name of the prompt optimization job.") 18 description: str | None = Field( 19 default=None, 20 description="A description of the prompt optimization job for you and your team.", 21 ) 22 job_id: str = Field(description="The ID of the job on the remote Kiln server.") 23 target_run_config_id: str = Field( 24 description="The ID of the run configuration used for this job." 25 ) 26 latest_status: str = Field( 27 default="pending", 28 description="The latest known status of this prompt optimization job (pending, running, succeeded, failed, cancelled). Not updated in real time.", 29 ) 30 optimized_prompt: str | None = Field( 31 default=None, 32 description="The optimized prompt result when the job succeeds.", 33 ) 34 created_prompt_id: str | None = Field( 35 default=None, 36 description="The ID of the prompt created from this job's result, if any.", 37 ) 38 created_run_config_id: str | None = Field( 39 default=None, 40 description="The ID of the run config created from this job's result, if any.", 41 ) 42 eval_ids: list[str] = Field( 43 default_factory=list, 44 description="List of eval IDs used for this job.", 45 ) 46 47 def parent_task(self) -> "Task | None": 48 """Get the parent task, with proper typing.""" 49 if self.parent is None or self.parent.__class__.__name__ != "Task": 50 return None 51 return self.parent # type: ignore
The Kiln prompt optimization job datamodel.
47 def parent_task(self) -> "Task | None": 48 """Get the parent task, with proper typing.""" 49 if self.parent is None or self.parent.__class__.__name__ != "Task": 50 return None 51 return self.parent # type: ignore
Get the parent task, with proper typing.
The type of the None singleton.
Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
365def init_private_attributes(self: BaseModel, context: Any, /) -> None: 366 """This function is meant to behave like a BaseModel method to initialize private attributes. 367 368 It takes context as an argument since that's what pydantic-core passes when calling it. 369 370 Args: 371 self: The BaseModel instance. 372 context: The context. 373 """ 374 if getattr(self, '__pydantic_private__', None) is None: 375 pydantic_private = {} 376 for name, private_attr in self.__private_attributes__.items(): 377 # Avoid needlessly creating a new dict for the validated data: 378 if private_attr.default_factory_takes_validated_data: 379 default = private_attr.get_default( 380 call_default_factory=True, validated_data={**self.__dict__, **pydantic_private} 381 ) 382 else: 383 default = private_attr.get_default(call_default_factory=True) 384 if default is not PydanticUndefined: 385 pydantic_private[name] = default 386 object_setattr(self, '__pydantic_private__', pydantic_private)
This function is meant to behave like a BaseModel method to initialize private attributes.
It takes context as an argument since that's what pydantic-core passes when calling it.
Args: self: The BaseModel instance. context: The context.
20class RequirementRating(BaseModel): 21 """Rating for a specific requirement within a task output.""" 22 23 value: float = Field( 24 description="The rating value. Interpretation depends on rating type" 25 ) 26 type: TaskOutputRatingType = Field(description="The type of rating")
Rating for a specific requirement within a task output.
20class Skill(KilnParentedModel): 21 """A Skill represents reusable agent instructions following the agentskills.io specification. 22 23 Skills are project-level resources that can be attached to run configs. 24 The agent discovers available skills via the skill tool description, then 25 loads a skill's body on demand by calling skill(name="skill_name"). 26 27 The skill's body (markdown instructions) is stored in a SKILL.md sidecar file 28 rather than in skill.kiln, following the agentskills.io spec. 29 """ 30 31 name: SkillNameString = Field( 32 description="Skill name. Kebab-case: lowercase alphanumeric with hyphens.", 33 ) 34 description: str = Field( 35 description="Description of what the skill does and when to use it.", 36 min_length=1, 37 max_length=1024, 38 ) 39 is_archived: bool = Field( 40 default=False, 41 description="Whether the skill is archived. Archived skills are hidden from the UI and not available for use.", 42 ) 43 44 def parent_project(self) -> Union["Project", None]: 45 if self.parent is None or self.parent.__class__.__name__ != "Project": 46 return None 47 return self.parent # type: ignore 48 49 def skill_md_path(self) -> Path: 50 """Path to the SKILL.md sidecar file (sibling of skill.kiln).""" 51 if self.path is None: 52 raise ValueError("Skill must be saved before accessing SKILL.md path") 53 return self.path.parent / SKILL_MD_FILENAME 54 55 def skill_md_raw(self) -> str: 56 """Read the full SKILL.md file content (frontmatter + body).""" 57 md_path = self.skill_md_path() 58 if not md_path.exists(): 59 raise FileNotFoundError(f"SKILL.md not found at {md_path}") 60 if md_path.is_dir(): 61 raise FileNotFoundError(f"SKILL.md path is a folder, not a file: {md_path}") 62 return md_path.read_text(encoding="utf-8") 63 64 def body(self) -> str: 65 """Read the markdown body from SKILL.md (content after YAML frontmatter).""" 66 return _parse_skill_md_body(self.skill_md_raw()) 67 68 # -- Resources (references & assets) -- 69 70 def references_dir(self) -> Path: 71 if self.path is None: 72 raise ValueError( 73 "Skill must be saved before accessing references directory" 74 ) 75 return self.path.parent / "references" 76 77 def assets_dir(self) -> Path: 78 if self.path is None: 79 raise ValueError("Skill must be saved before accessing assets directory") 80 return self.path.parent / "assets" 81 82 def read_reference(self, relative_path: str) -> str: 83 """Read a reference file. Raises ValueError for path traversal, non-text, or if the path is a folder, FileNotFoundError if missing.""" 84 return self._read_resource(self.references_dir(), relative_path) 85 86 def read_asset(self, relative_path: str) -> str: 87 """Read an asset file. Raises ValueError for path traversal, non-text, or if the path is a folder, FileNotFoundError if missing.""" 88 return self._read_resource(self.assets_dir(), relative_path) 89 90 def _read_resource(self, base_dir: Path, relative_path: str) -> str: 91 """Read a resource file, validating it resolves within base_dir and is readable text.""" 92 if not relative_path or not relative_path.strip(): 93 raise ValueError("Path cannot be empty") 94 95 target = base_dir / relative_path 96 try: 97 resolved = target.resolve() 98 resolved.relative_to(base_dir.resolve()) 99 except ValueError: 100 raise ValueError("Path traversal is not allowed") from None 101 102 if resolved.is_dir(): 103 raise ValueError(f"Path is a folder, not a file: {relative_path}") 104 105 try: 106 return resolved.read_text(encoding="utf-8") 107 except FileNotFoundError: 108 raise FileNotFoundError( 109 f"Resource file not found: {relative_path}" 110 ) from None 111 except UnicodeDecodeError: 112 raise ValueError( 113 f"File is not a readable text file: {relative_path}" 114 ) from None 115 116 def save_skill_md(self, body: str) -> None: 117 """Write SKILL.md with YAML frontmatter (name, description) + markdown body. 118 119 Reads name and description from self to keep SKILL.md in sync with skill.kiln. 120 """ 121 if not body or not body.strip(): 122 raise ValueError("body must be non-empty") 123 frontmatter = yaml.dump( 124 {"name": self.name, "description": self.description}, 125 default_flow_style=False, 126 allow_unicode=True, 127 sort_keys=False, 128 ).rstrip("\n") 129 content = f"---\n{frontmatter}\n---\n\n{body}" 130 self.skill_md_path().write_text(content, encoding="utf-8") 131 self.references_dir().mkdir(exist_ok=True) 132 self.assets_dir().mkdir(exist_ok=True)
A Skill represents reusable agent instructions following the agentskills.io specification.
Skills are project-level resources that can be attached to run configs. The agent discovers available skills via the skill tool description, then loads a skill's body on demand by calling skill(name="skill_name").
The skill's body (markdown instructions) is stored in a SKILL.md sidecar file rather than in skill.kiln, following the agentskills.io spec.
49 def skill_md_path(self) -> Path: 50 """Path to the SKILL.md sidecar file (sibling of skill.kiln).""" 51 if self.path is None: 52 raise ValueError("Skill must be saved before accessing SKILL.md path") 53 return self.path.parent / SKILL_MD_FILENAME
Path to the SKILL.md sidecar file (sibling of skill.kiln).
55 def skill_md_raw(self) -> str: 56 """Read the full SKILL.md file content (frontmatter + body).""" 57 md_path = self.skill_md_path() 58 if not md_path.exists(): 59 raise FileNotFoundError(f"SKILL.md not found at {md_path}") 60 if md_path.is_dir(): 61 raise FileNotFoundError(f"SKILL.md path is a folder, not a file: {md_path}") 62 return md_path.read_text(encoding="utf-8")
Read the full SKILL.md file content (frontmatter + body).
64 def body(self) -> str: 65 """Read the markdown body from SKILL.md (content after YAML frontmatter).""" 66 return _parse_skill_md_body(self.skill_md_raw())
Read the markdown body from SKILL.md (content after YAML frontmatter).
82 def read_reference(self, relative_path: str) -> str: 83 """Read a reference file. Raises ValueError for path traversal, non-text, or if the path is a folder, FileNotFoundError if missing.""" 84 return self._read_resource(self.references_dir(), relative_path)
Read a reference file. Raises ValueError for path traversal, non-text, or if the path is a folder, FileNotFoundError if missing.
86 def read_asset(self, relative_path: str) -> str: 87 """Read an asset file. Raises ValueError for path traversal, non-text, or if the path is a folder, FileNotFoundError if missing.""" 88 return self._read_resource(self.assets_dir(), relative_path)
Read an asset file. Raises ValueError for path traversal, non-text, or if the path is a folder, FileNotFoundError if missing.
116 def save_skill_md(self, body: str) -> None: 117 """Write SKILL.md with YAML frontmatter (name, description) + markdown body. 118 119 Reads name and description from self to keep SKILL.md in sync with skill.kiln. 120 """ 121 if not body or not body.strip(): 122 raise ValueError("body must be non-empty") 123 frontmatter = yaml.dump( 124 {"name": self.name, "description": self.description}, 125 default_flow_style=False, 126 allow_unicode=True, 127 sort_keys=False, 128 ).rstrip("\n") 129 content = f"---\n{frontmatter}\n---\n\n{body}" 130 self.skill_md_path().write_text(content, encoding="utf-8") 131 self.references_dir().mkdir(exist_ok=True) 132 self.assets_dir().mkdir(exist_ok=True)
Write SKILL.md with YAML frontmatter (name, description) + markdown body.
Reads name and description from self to keep SKILL.md in sync with skill.kiln.
The type of the None singleton.
Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
365def init_private_attributes(self: BaseModel, context: Any, /) -> None: 366 """This function is meant to behave like a BaseModel method to initialize private attributes. 367 368 It takes context as an argument since that's what pydantic-core passes when calling it. 369 370 Args: 371 self: The BaseModel instance. 372 context: The context. 373 """ 374 if getattr(self, '__pydantic_private__', None) is None: 375 pydantic_private = {} 376 for name, private_attr in self.__private_attributes__.items(): 377 # Avoid needlessly creating a new dict for the validated data: 378 if private_attr.default_factory_takes_validated_data: 379 default = private_attr.get_default( 380 call_default_factory=True, validated_data={**self.__dict__, **pydantic_private} 381 ) 382 else: 383 default = private_attr.get_default(call_default_factory=True) 384 if default is not PydanticUndefined: 385 pydantic_private[name] = default 386 object_setattr(self, '__pydantic_private__', pydantic_private)
This function is meant to behave like a BaseModel method to initialize private attributes.
It takes context as an argument since that's what pydantic-core passes when calling it.
Args: self: The BaseModel instance. context: The context.
37class StructuredOutputMode(str, Enum): 38 """ 39 Enumeration of supported structured output modes. 40 41 - json_schema: request json using API capabilities for json_schema 42 - function_calling: request json using API capabilities for function calling 43 - json_mode: request json using API's JSON mode, which should return valid JSON, but isn't checking/passing the schema 44 - json_instructions: append instructions to the prompt to request json matching the schema. No API capabilities are used. You should have a custom parser on these models as they will be returning strings. 45 - json_instruction_and_object: append instructions to the prompt to request json matching the schema. Also request the response as json_mode via API capabilities (returning dictionaries). 46 - json_custom_instructions: The model should output JSON, but custom instructions are already included in the system prompt. Don't append additional JSON instructions. 47 - default: let the adapter decide (legacy, do not use for new use cases) 48 - unknown: used for cases where the structured output mode is not known (on old models where it wasn't saved). Should lookup best option at runtime. 49 """ 50 51 default = "default" 52 json_schema = "json_schema" 53 function_calling_weak = "function_calling_weak" 54 function_calling = "function_calling" 55 json_mode = "json_mode" 56 json_instructions = "json_instructions" 57 json_instruction_and_object = "json_instruction_and_object" 58 json_custom_instructions = "json_custom_instructions" 59 unknown = "unknown"
Enumeration of supported structured output modes.
- json_schema: request json using API capabilities for json_schema
- function_calling: request json using API capabilities for function calling
- json_mode: request json using API's JSON mode, which should return valid JSON, but isn't checking/passing the schema
- json_instructions: append instructions to the prompt to request json matching the schema. No API capabilities are used. You should have a custom parser on these models as they will be returning strings.
- json_instruction_and_object: append instructions to the prompt to request json matching the schema. Also request the response as json_mode via API capabilities (returning dictionaries).
- json_custom_instructions: The model should output JSON, but custom instructions are already included in the system prompt. Don't append additional JSON instructions.
- default: let the adapter decide (legacy, do not use for new use cases)
- unknown: used for cases where the structured output mode is not known (on old models where it wasn't saved). Should lookup best option at runtime.
128class Task( 129 KilnParentedModel, 130 KilnParentModel, 131 parent_of={ 132 "_runs": ParentOfRelationship(model=TaskRun, filesystem_name="runs"), 133 "dataset_splits": DatasetSplit, 134 "finetunes": Finetune, 135 "prompt_optimization_jobs": PromptOptimizationJob, 136 "prompts": Prompt, 137 "evals": Eval, 138 "eval_inputs": EvalInput, 139 "specs": Spec, 140 "run_configs": TaskRunConfig, 141 "data_guides": DataGuide, 142 }, 143): 144 """ 145 Represents a specific task to be performed, with associated requirements and validation rules. 146 147 Contains the task definition, requirements, input/output schemas, and maintains 148 a collection of task runs. 149 """ 150 151 name: FilenameString = Field(description="The name of the task.") 152 description: str | None = Field( 153 default=None, 154 description="A description of the task for you and your team. Will not be used in prompts/training/validation.", 155 ) 156 instruction: str = Field( 157 min_length=1, 158 description="The instructions for the task. Will be used in prompts/training/validation.", 159 ) 160 requirements: List[TaskRequirement] = Field( 161 default=[], 162 description="Deprecated: Use specs and prompts instead.", 163 ) 164 output_json_schema: JsonObjectSchema | None = Field( 165 default=None, 166 description="JSON schema for structured task output. Must be an object schema.", 167 ) 168 input_json_schema: JsonSchema | None = Field( 169 default=None, 170 description="JSON schema for structured task input. Can be an object or array schema.", 171 ) 172 thinking_instruction: str | None = Field( 173 default=None, 174 description="Instructions for the model 'thinking' about the requirement prior to answering. Used for chain of thought style prompting.", 175 ) 176 177 default_run_config_id: ID_TYPE | None = Field( 178 default=None, 179 description="ID of the run config to use for this task by default. Must exist in saved run configs for this task.", 180 ) 181 182 def output_schema(self) -> Dict | None: 183 if self.output_json_schema is None: 184 return None 185 return schema_from_json_str(self.output_json_schema) 186 187 def input_schema(self) -> Dict | None: 188 if self.input_json_schema is None: 189 return None 190 # Allow arrays, not just objects 191 return schema_from_json_str(self.input_json_schema, require_object=False) 192 193 def runs( 194 self, 195 readonly: bool = False, 196 include_intermediate_runs: bool = False, 197 include_eval_generated: bool = False, 198 ) -> list[TaskRun]: 199 """Return TaskRuns for this task with leaf-only, dataset-only filtering by default. 200 201 For multiturn tasks, child TaskRuns reference their parent via 202 ``parent_task_run_id``. By default we return only the leaves of those 203 chains - the runs that aren't a parent of any other run - because that 204 is the right view for the vast majority of consumers iterating runs 205 in-process (dataset/eval/finetune sample iteration, summary lists, 206 statistics, tag counts, etc.). 207 208 Pass ``include_intermediate_runs=True`` to get every run regardless of 209 position in the chain. That is only correct for code that needs the 210 complete on-disk set (e.g. walking ancestors, diagnostics). For 211 single-turn tasks the two modes are equivalent. 212 213 ``runs/`` also holds eval-generated traces (``eval_source`` set) alongside the 214 dataset corpus. Those are excluded by default: they are a byproduct of running 215 an eval, not data the user curated, and leaking them into fine-tune sets or 216 few-shot prompts would feed a model its own eval outputs. Default-exclude rather 217 than an opt-out filter is deliberate - forgetting to handle eval traces then 218 fails visibly (missing data) instead of silently (contaminated data). 219 ``include_eval_generated=True`` is meant for the eval runner alone, which needs 220 to find the traces it can reuse; nothing else should pass it. 221 222 The two filters compose: a run must pass both to be returned. 223 224 Note: these filters only affect in-process iteration. Filesystem-level 225 operations that copy the ``runs/`` directory (e.g. project export) 226 copy every run regardless. 227 """ 228 runs = self._runs(readonly=readonly) # type: ignore[attr-defined] 229 if not include_intermediate_runs: 230 parent_ids = {r.parent_task_run_id for r in runs if r.parent_task_run_id} 231 runs = [r for r in runs if r.id not in parent_ids] 232 if not include_eval_generated: 233 runs = [r for r in runs if r.eval_source is None] 234 return runs 235 236 # These wrappers help for typechecking. We should fix this in KilnParentModel 237 def dataset_splits(self, readonly: bool = False) -> list[DatasetSplit]: 238 return super().dataset_splits(readonly=readonly) # type: ignore 239 240 def finetunes(self, readonly: bool = False) -> list[Finetune]: 241 return super().finetunes(readonly=readonly) # type: ignore 242 243 def prompts(self, readonly: bool = False) -> list[Prompt]: 244 return super().prompts(readonly=readonly) # type: ignore 245 246 def evals(self, readonly: bool = False) -> list[Eval]: 247 return super().evals(readonly=readonly) # type: ignore 248 249 def eval_inputs(self, readonly: bool = False) -> list[EvalInput]: 250 return super().eval_inputs(readonly=readonly) # type: ignore 251 252 def run_configs(self, readonly: bool = False) -> list[TaskRunConfig]: 253 return super().run_configs(readonly=readonly) # type: ignore 254 255 def specs(self, readonly: bool = False) -> list[Spec]: 256 return super().specs(readonly=readonly) # type: ignore 257 258 def data_guides(self, readonly: bool = False) -> list[DataGuide]: 259 return super().data_guides(readonly=readonly) # type: ignore 260 261 def current_data_guide(self, readonly: bool = False) -> DataGuide | None: 262 # By design there is at most one DataGuide per task — saves overwrite 263 # the existing one in place rather than creating a new file. If the 264 # folder somehow ends up with multiple (e.g. an older import), return 265 # the first; cleanup is up to the caller. 266 guides = self.data_guides(readonly=readonly) 267 return guides[0] if guides else None 268 269 def prompt_optimization_jobs( 270 self, readonly: bool = False 271 ) -> list[PromptOptimizationJob]: 272 return super().prompt_optimization_jobs(readonly=readonly) # type: ignore 273 274 # Workaround to return typed parent without importing Task 275 def parent_project(self) -> Union["Project", None]: 276 if self.parent is None or self.parent.__class__.__name__ != "Project": 277 return None 278 return self.parent # type: ignore
Represents a specific task to be performed, with associated requirements and validation rules.
Contains the task definition, requirements, input/output schemas, and maintains a collection of task runs.
193 def runs( 194 self, 195 readonly: bool = False, 196 include_intermediate_runs: bool = False, 197 include_eval_generated: bool = False, 198 ) -> list[TaskRun]: 199 """Return TaskRuns for this task with leaf-only, dataset-only filtering by default. 200 201 For multiturn tasks, child TaskRuns reference their parent via 202 ``parent_task_run_id``. By default we return only the leaves of those 203 chains - the runs that aren't a parent of any other run - because that 204 is the right view for the vast majority of consumers iterating runs 205 in-process (dataset/eval/finetune sample iteration, summary lists, 206 statistics, tag counts, etc.). 207 208 Pass ``include_intermediate_runs=True`` to get every run regardless of 209 position in the chain. That is only correct for code that needs the 210 complete on-disk set (e.g. walking ancestors, diagnostics). For 211 single-turn tasks the two modes are equivalent. 212 213 ``runs/`` also holds eval-generated traces (``eval_source`` set) alongside the 214 dataset corpus. Those are excluded by default: they are a byproduct of running 215 an eval, not data the user curated, and leaking them into fine-tune sets or 216 few-shot prompts would feed a model its own eval outputs. Default-exclude rather 217 than an opt-out filter is deliberate - forgetting to handle eval traces then 218 fails visibly (missing data) instead of silently (contaminated data). 219 ``include_eval_generated=True`` is meant for the eval runner alone, which needs 220 to find the traces it can reuse; nothing else should pass it. 221 222 The two filters compose: a run must pass both to be returned. 223 224 Note: these filters only affect in-process iteration. Filesystem-level 225 operations that copy the ``runs/`` directory (e.g. project export) 226 copy every run regardless. 227 """ 228 runs = self._runs(readonly=readonly) # type: ignore[attr-defined] 229 if not include_intermediate_runs: 230 parent_ids = {r.parent_task_run_id for r in runs if r.parent_task_run_id} 231 runs = [r for r in runs if r.id not in parent_ids] 232 if not include_eval_generated: 233 runs = [r for r in runs if r.eval_source is None] 234 return runs
Return TaskRuns for this task with leaf-only, dataset-only filtering by default.
For multiturn tasks, child TaskRuns reference their parent via
parent_task_run_id. By default we return only the leaves of those
chains - the runs that aren't a parent of any other run - because that
is the right view for the vast majority of consumers iterating runs
in-process (dataset/eval/finetune sample iteration, summary lists,
statistics, tag counts, etc.).
Pass include_intermediate_runs=True to get every run regardless of
position in the chain. That is only correct for code that needs the
complete on-disk set (e.g. walking ancestors, diagnostics). For
single-turn tasks the two modes are equivalent.
runs/ also holds eval-generated traces (eval_source set) alongside the
dataset corpus. Those are excluded by default: they are a byproduct of running
an eval, not data the user curated, and leaking them into fine-tune sets or
few-shot prompts would feed a model its own eval outputs. Default-exclude rather
than an opt-out filter is deliberate - forgetting to handle eval traces then
fails visibly (missing data) instead of silently (contaminated data).
include_eval_generated=True is meant for the eval runner alone, which needs
to find the traces it can reuse; nothing else should pass it.
The two filters compose: a run must pass both to be returned.
Note: these filters only affect in-process iteration. Filesystem-level
operations that copy the runs/ directory (e.g. project export)
copy every run regardless.
838 def child_method(self, readonly: bool = False) -> list[child_class]: # type: ignore[invalid-type-form] 839 return child_class.all_children_of_parent_path(self.path, readonly=readonly)
The type of the None singleton.
838 def child_method(self, readonly: bool = False) -> list[child_class]: # type: ignore[invalid-type-form] 839 return child_class.all_children_of_parent_path(self.path, readonly=readonly)
The type of the None singleton.
838 def child_method(self, readonly: bool = False) -> list[child_class]: # type: ignore[invalid-type-form] 839 return child_class.all_children_of_parent_path(self.path, readonly=readonly)
The type of the None singleton.
838 def child_method(self, readonly: bool = False) -> list[child_class]: # type: ignore[invalid-type-form] 839 return child_class.all_children_of_parent_path(self.path, readonly=readonly)
The type of the None singleton.
838 def child_method(self, readonly: bool = False) -> list[child_class]: # type: ignore[invalid-type-form] 839 return child_class.all_children_of_parent_path(self.path, readonly=readonly)
The type of the None singleton.
838 def child_method(self, readonly: bool = False) -> list[child_class]: # type: ignore[invalid-type-form] 839 return child_class.all_children_of_parent_path(self.path, readonly=readonly)
The type of the None singleton.
838 def child_method(self, readonly: bool = False) -> list[child_class]: # type: ignore[invalid-type-form] 839 return child_class.all_children_of_parent_path(self.path, readonly=readonly)
The type of the None singleton.
838 def child_method(self, readonly: bool = False) -> list[child_class]: # type: ignore[invalid-type-form] 839 return child_class.all_children_of_parent_path(self.path, readonly=readonly)
The type of the None singleton.
261 def current_data_guide(self, readonly: bool = False) -> DataGuide | None: 262 # By design there is at most one DataGuide per task — saves overwrite 263 # the existing one in place rather than creating a new file. If the 264 # folder somehow ends up with multiple (e.g. an older import), return 265 # the first; cleanup is up to the caller. 266 guides = self.data_guides(readonly=readonly) 267 return guides[0] if guides else None
838 def child_method(self, readonly: bool = False) -> list[child_class]: # type: ignore[invalid-type-form] 839 return child_class.all_children_of_parent_path(self.path, readonly=readonly)
The type of the None singleton.
The type of the None singleton.
Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
365def init_private_attributes(self: BaseModel, context: Any, /) -> None: 366 """This function is meant to behave like a BaseModel method to initialize private attributes. 367 368 It takes context as an argument since that's what pydantic-core passes when calling it. 369 370 Args: 371 self: The BaseModel instance. 372 context: The context. 373 """ 374 if getattr(self, '__pydantic_private__', None) is None: 375 pydantic_private = {} 376 for name, private_attr in self.__private_attributes__.items(): 377 # Avoid needlessly creating a new dict for the validated data: 378 if private_attr.default_factory_takes_validated_data: 379 default = private_attr.get_default( 380 call_default_factory=True, validated_data={**self.__dict__, **pydantic_private} 381 ) 382 else: 383 default = private_attr.get_default(call_default_factory=True) 384 if default is not PydanticUndefined: 385 pydantic_private[name] = default 386 object_setattr(self, '__pydantic_private__', pydantic_private)
This function is meant to behave like a BaseModel method to initialize private attributes.
It takes context as an argument since that's what pydantic-core passes when calling it.
Args: self: The BaseModel instance. context: The context.
338class TaskOutput(KilnBaseModel): 339 """ 340 An output for a specific task run. 341 342 Contains the actual output content, its source (human or synthetic), 343 and optional rating information. 344 """ 345 346 output: str = Field( 347 description="The output of the task. JSON formatted for structured output, plaintext for unstructured output." 348 ) 349 source: DataSource | None = Field( 350 description="The source of the output: human or synthetic.", 351 default=None, 352 ) 353 rating: TaskOutputRating | None = Field( 354 default=None, description="The rating of the output" 355 ) 356 357 def validate_output_format(self, task: "Task") -> Self: 358 # validate output 359 if task.output_json_schema is not None: 360 try: 361 output_parsed = json.loads(self.output) 362 except json.JSONDecodeError: 363 raise ValueError("Output is not a valid JSON object") 364 365 validate_schema_with_value_error( 366 output_parsed, 367 task.output_json_schema, 368 "This task requires a specific output schema. While the model produced JSON, that JSON didn't meet the schema. Search 'Troubleshooting Structured Data Issues' in our docs for more information.", 369 ) 370 return self 371 372 @model_validator(mode="after") 373 def validate_output_source(self, info: ValidationInfo) -> Self: 374 # On strict mode and not loaded from file, we validate output_source is not None. 375 # We want to be able to load any data, even if it's not perfect. But we want to create perfect data when adding new data. 376 if not strict_mode(): 377 return self 378 if self.loaded_from_file(info): 379 return self 380 if self.source is None: 381 raise ValueError("Output source is required when strict mode is enabled") 382 return self
An output for a specific task run.
Contains the actual output content, its source (human or synthetic), and optional rating information.
357 def validate_output_format(self, task: "Task") -> Self: 358 # validate output 359 if task.output_json_schema is not None: 360 try: 361 output_parsed = json.loads(self.output) 362 except json.JSONDecodeError: 363 raise ValueError("Output is not a valid JSON object") 364 365 validate_schema_with_value_error( 366 output_parsed, 367 task.output_json_schema, 368 "This task requires a specific output schema. While the model produced JSON, that JSON didn't meet the schema. Search 'Troubleshooting Structured Data Issues' in our docs for more information.", 369 ) 370 return self
372 @model_validator(mode="after") 373 def validate_output_source(self, info: ValidationInfo) -> Self: 374 # On strict mode and not loaded from file, we validate output_source is not None. 375 # We want to be able to load any data, even if it's not perfect. But we want to create perfect data when adding new data. 376 if not strict_mode(): 377 return self 378 if self.loaded_from_file(info): 379 return self 380 if self.source is None: 381 raise ValueError("Output source is required when strict mode is enabled") 382 return self
Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
365def init_private_attributes(self: BaseModel, context: Any, /) -> None: 366 """This function is meant to behave like a BaseModel method to initialize private attributes. 367 368 It takes context as an argument since that's what pydantic-core passes when calling it. 369 370 Args: 371 self: The BaseModel instance. 372 context: The context. 373 """ 374 if getattr(self, '__pydantic_private__', None) is None: 375 pydantic_private = {} 376 for name, private_attr in self.__private_attributes__.items(): 377 # Avoid needlessly creating a new dict for the validated data: 378 if private_attr.default_factory_takes_validated_data: 379 default = private_attr.get_default( 380 call_default_factory=True, validated_data={**self.__dict__, **pydantic_private} 381 ) 382 else: 383 default = private_attr.get_default(call_default_factory=True) 384 if default is not PydanticUndefined: 385 pydantic_private[name] = default 386 object_setattr(self, '__pydantic_private__', pydantic_private)
This function is meant to behave like a BaseModel method to initialize private attributes.
It takes context as an argument since that's what pydantic-core passes when calling it.
Args: self: The BaseModel instance. context: The context.
50class TaskOutputRating(KilnBaseModel): 51 """ 52 A rating for a task output, including an overall rating and ratings for each requirement. 53 54 Supports: 55 - five_star: 1-5 star ratings 56 - pass_fail: boolean pass/fail (1.0 = pass, 0.0 = fail) 57 - pass_fail_critical: tri-state (1.0 = pass, 0.0 = fail, -1.0 = critical fail) 58 """ 59 60 type: TaskOutputRatingType = Field( 61 default=TaskOutputRatingType.five_star, 62 description="The rating system used for this rating.", 63 ) 64 value: float | None = Field( 65 description="The rating value. Interpretation depends on rating type:\n- five_star: 1-5 stars\n- pass_fail: 1.0 (pass) or 0.0 (fail)\n- pass_fail_critical: 1.0 (pass), 0.0 (fail), or -1.0 (critical fail)", 66 default=None, 67 ) 68 requirement_ratings: Dict[ID_TYPE, RequirementRating] = Field( 69 default={}, 70 description="The ratings of the requirements of the task. The ID can be either a task_requirement_id or a named rating for an eval_output_score name (in format 'named::<name>').", 71 ) 72 73 # Previously we stored rating values as a dict of floats, but now we store them as RequirementRating objects. 74 @model_validator(mode="before") 75 def upgrade_old_format(cls, data: dict) -> dict: 76 if not isinstance(data, dict): 77 return data 78 79 # Check if we have the old format (dict of floats) 80 req_ratings = data.get("requirement_ratings", {}) 81 if req_ratings and all( 82 isinstance(v, (int, float)) for v in req_ratings.values() 83 ): 84 # Convert each float to a RequirementRating object 85 # all ratings are five star at the point we used this format 86 data["requirement_ratings"] = { 87 k: {"value": v, "type": TaskOutputRatingType.five_star} 88 for k, v in req_ratings.items() 89 } 90 91 return data 92 93 # Used to select high quality outputs for example selection (MultiShotPromptBuilder, etc) 94 def is_high_quality(self) -> bool: 95 if self.value is None: 96 return False 97 98 if self.type == TaskOutputRatingType.five_star: 99 return self.value >= 4 100 elif self.type == TaskOutputRatingType.pass_fail: 101 return self.value == 1.0 102 elif self.type == TaskOutputRatingType.pass_fail_critical: 103 return self.value == 1.0 104 return False 105 106 @model_validator(mode="after") 107 def validate_rating(self) -> Self: 108 if self.type not in TaskOutputRatingType: 109 raise ValueError(f"Invalid rating type: {self.type}") 110 111 # Overall rating is optional 112 if self.value is not None: 113 self._validate_rating(self.type, self.value, "overall rating") 114 115 for req_id, req_rating in self.requirement_ratings.items(): 116 self._validate_rating( 117 req_rating.type, 118 req_rating.value, 119 f"requirement rating for req ID: {req_id}", 120 ) 121 122 return self 123 124 def _validate_rating( 125 self, type: TaskOutputRatingType, rating: float | None, rating_name: str 126 ) -> None: 127 if type == TaskOutputRatingType.five_star: 128 self._validate_five_star(rating, rating_name) 129 elif type == TaskOutputRatingType.pass_fail: 130 self._validate_pass_fail(rating, rating_name) 131 elif type == TaskOutputRatingType.pass_fail_critical: 132 self._validate_pass_fail_critical(rating, rating_name) 133 134 def _validate_five_star(self, rating: float | None, rating_name: str) -> None: 135 if rating is None or not isinstance(rating, float) or not rating.is_integer(): 136 raise ValueError( 137 f"{rating_name.capitalize()} of type five_star must be an integer value (1-5)" 138 ) 139 if rating < 1 or rating > 5: 140 raise ValueError( 141 f"{rating_name.capitalize()} of type five_star must be between 1 and 5 stars" 142 ) 143 144 def _validate_pass_fail(self, rating: float | None, rating_name: str) -> None: 145 if rating is None or not isinstance(rating, float) or not rating.is_integer(): 146 raise ValueError( 147 f"{rating_name.capitalize()} of type pass_fail must be an integer value (0 or 1)" 148 ) 149 if rating not in [0, 1]: 150 raise ValueError( 151 f"{rating_name.capitalize()} of type pass_fail must be 0 (fail) or 1 (pass)" 152 ) 153 154 def _validate_pass_fail_critical( 155 self, rating: float | None, rating_name: str 156 ) -> None: 157 if rating is None or not isinstance(rating, float) or not rating.is_integer(): 158 raise ValueError( 159 f"{rating_name.capitalize()} of type pass_fail_critical must be an integer value (-1, 0, or 1)" 160 ) 161 if rating not in [-1, 0, 1]: 162 raise ValueError( 163 f"{rating_name.capitalize()} of type pass_fail_critical must be -1 (critical fail), 0 (fail), or 1 (pass)" 164 )
A rating for a task output, including an overall rating and ratings for each requirement.
Supports:
- five_star: 1-5 star ratings
- pass_fail: boolean pass/fail (1.0 = pass, 0.0 = fail)
- pass_fail_critical: tri-state (1.0 = pass, 0.0 = fail, -1.0 = critical fail)
74 @model_validator(mode="before") 75 def upgrade_old_format(cls, data: dict) -> dict: 76 if not isinstance(data, dict): 77 return data 78 79 # Check if we have the old format (dict of floats) 80 req_ratings = data.get("requirement_ratings", {}) 81 if req_ratings and all( 82 isinstance(v, (int, float)) for v in req_ratings.values() 83 ): 84 # Convert each float to a RequirementRating object 85 # all ratings are five star at the point we used this format 86 data["requirement_ratings"] = { 87 k: {"value": v, "type": TaskOutputRatingType.five_star} 88 for k, v in req_ratings.items() 89 } 90 91 return data
94 def is_high_quality(self) -> bool: 95 if self.value is None: 96 return False 97 98 if self.type == TaskOutputRatingType.five_star: 99 return self.value >= 4 100 elif self.type == TaskOutputRatingType.pass_fail: 101 return self.value == 1.0 102 elif self.type == TaskOutputRatingType.pass_fail_critical: 103 return self.value == 1.0 104 return False
106 @model_validator(mode="after") 107 def validate_rating(self) -> Self: 108 if self.type not in TaskOutputRatingType: 109 raise ValueError(f"Invalid rating type: {self.type}") 110 111 # Overall rating is optional 112 if self.value is not None: 113 self._validate_rating(self.type, self.value, "overall rating") 114 115 for req_id, req_rating in self.requirement_ratings.items(): 116 self._validate_rating( 117 req_rating.type, 118 req_rating.value, 119 f"requirement rating for req ID: {req_id}", 120 ) 121 122 return self
Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
365def init_private_attributes(self: BaseModel, context: Any, /) -> None: 366 """This function is meant to behave like a BaseModel method to initialize private attributes. 367 368 It takes context as an argument since that's what pydantic-core passes when calling it. 369 370 Args: 371 self: The BaseModel instance. 372 context: The context. 373 """ 374 if getattr(self, '__pydantic_private__', None) is None: 375 pydantic_private = {} 376 for name, private_attr in self.__private_attributes__.items(): 377 # Avoid needlessly creating a new dict for the validated data: 378 if private_attr.default_factory_takes_validated_data: 379 default = private_attr.get_default( 380 call_default_factory=True, validated_data={**self.__dict__, **pydantic_private} 381 ) 382 else: 383 default = private_attr.get_default(call_default_factory=True) 384 if default is not PydanticUndefined: 385 pydantic_private[name] = default 386 object_setattr(self, '__pydantic_private__', pydantic_private)
This function is meant to behave like a BaseModel method to initialize private attributes.
It takes context as an argument since that's what pydantic-core passes when calling it.
Args: self: The BaseModel instance. context: The context.
28class TaskOutputRatingType(str, Enum): 29 """Defines the types of rating systems available for task outputs.""" 30 31 five_star = "five_star" 32 pass_fail = "pass_fail" 33 pass_fail_critical = "pass_fail_critical" 34 custom = "custom"
Defines the types of rating systems available for task outputs.
39class TaskRequirement(BaseModel): 40 """ 41 Defines a specific requirement that should be met by task outputs. 42 43 Includes an identifier, name, description, instruction for meeting the requirement, 44 priority level, and rating type (five_star, pass_fail, pass_fail_critical, custom). 45 """ 46 47 id: ID_TYPE = ID_FIELD 48 name: FilenameStringShort = Field(description="The name of the task requirement.") 49 description: str | None = Field( 50 default=None, 51 description="Optional elaboration on the requirement's purpose.", 52 ) 53 instruction: str = Field( 54 min_length=1, description="Instructions for meeting the requirement." 55 ) 56 priority: Priority = Field( 57 default=Priority.p2, description="The priority level of the requirement." 58 ) 59 type: TaskOutputRatingType = Field( 60 default=TaskOutputRatingType.five_star, 61 description="The rating type used to evaluate this requirement.", 62 )
Defines a specific requirement that should be met by task outputs.
Includes an identifier, name, description, instruction for meeting the requirement, priority level, and rating type (five_star, pass_fail, pass_fail_critical, custom).
59class TaskRun( 60 KilnParentedModel, 61 KilnParentModel, 62 parent_of={ 63 "feedback": Feedback, 64 }, 65): 66 """ 67 Represents a single execution of a Task. 68 69 Contains the input used, its source, the output produced, and optional 70 repair information if the output needed correction. 71 """ 72 73 input: str = Field( 74 description="The inputs to the task. JSON formatted for structured input, plaintext for unstructured input." 75 ) 76 input_source: DataSource | None = Field( 77 default=None, description="The source of the input: human or synthetic." 78 ) 79 80 output: TaskOutput = Field(description="The output of the task run.") 81 repair_instructions: str | None = Field( 82 default=None, 83 description="Instructions for fixing the output. Should define what is wrong, and how to fix it. Will be used by models for both generating a fixed output, and evaluating future models.", 84 ) 85 repaired_output: TaskOutput | None = Field( 86 default=None, 87 description="An version of the output with issues fixed. This must be a 'fixed' version of the existing output, and not an entirely new output. If you wish to generate an ideal curatorial output for this task unrelated to this output, generate a new TaskOutput with type 'human' instead of using this field.", 88 ) 89 intermediate_outputs: Dict[str, str] | None = Field( 90 default=None, 91 description="Intermediate outputs from the task run. Keys are the names of the intermediate output steps (cot=chain of thought, etc), values are the output data.", 92 ) 93 tags: List[str] = Field( 94 default=[], 95 description="Tags for the task run. Tags are used to categorize task runs for filtering and reporting.", 96 ) 97 usage: Usage | None = Field( 98 default=None, 99 description="Usage information for the task run. This includes the number of input tokens, output tokens, and total tokens used.", 100 ) 101 cumulative_usage: MessageUsage | None = Field( 102 default=None, 103 description=( 104 "Sum of per-message token usage and cost across the entire trace, " 105 "including any seeded prior trace. None on records created before " 106 "this field existed. For a fresh (non-seeded) run, the token / " 107 "cost fields equal those of `usage`." 108 ), 109 ) 110 trace: list[ChatCompletionMessageParam] | None = Field( 111 default=None, 112 description="The trace of the task run in OpenAI format. This is the list of messages that were sent to/from the model.", 113 ) 114 parent_task_run_id: str | None = Field( 115 default=None, 116 description="The ID of the parent task run. This is the ID of the task run that contains this task run.", 117 ) 118 eval_source: EvalItemSource | None = Field( 119 default=None, 120 description="Set when this run was generated by an eval. Names the eval dataset item it was generated for. None for ordinary dataset runs. Runs with this set are excluded from Task.runs() by default, so they do not appear on dataset surfaces.", 121 ) 122 123 @property 124 def is_toolcall_pending(self) -> bool: 125 """True if the trace ends with an assistant message awaiting client tool execution.""" 126 return trace_has_pending_client_tool_calls(self.trace) 127 128 def thinking_training_data(self) -> str | None: 129 """ 130 Get the thinking training data from the task run. 131 """ 132 if self.intermediate_outputs is None: 133 return None 134 return self.intermediate_outputs.get( 135 "reasoning" 136 ) or self.intermediate_outputs.get("chain_of_thought") 137 138 def has_thinking_training_data(self) -> bool: 139 """ 140 Does this run have thinking data that we can use to train a thinking model? 141 """ 142 return self.thinking_training_data() is not None 143 144 def feedback(self, readonly: bool = False) -> list[Feedback]: 145 return super().feedback(readonly=readonly) # type: ignore 146 147 # Workaround to return typed parent without importing Task 148 def parent_task(self) -> Union["Task", None]: 149 if self.parent is None or self.parent.__class__.__name__ != "Task": 150 return None 151 return self.parent # type: ignore 152 153 @model_validator(mode="after") 154 def validate_input_format(self, info: ValidationInfo) -> Self: 155 # Don't validate if loading from file (not new). Too slow. 156 # We don't allow changing task schema, so this is redundant validation. 157 # Note: we still validate if editing a loaded model 158 if self.loading_from_file(info): 159 # Consider loading an existing model as validated. 160 self._last_validated_input = self.input 161 return self 162 163 # Don't validate if input has not changed. Too slow to run this every time. 164 if ( 165 hasattr(self, "_last_validated_input") 166 and self.input == self._last_validated_input 167 ): 168 return self 169 170 task = self.parent_task() 171 if task is None: 172 # don't validate this relationship until we have a path or parent. Give them time to build it (but will catch it before saving) 173 return self 174 175 # validate input 176 if task.input_json_schema is not None: 177 try: 178 input_parsed = json.loads(self.input) 179 except json.JSONDecodeError: 180 raise ValueError("Input is not a valid JSON object") 181 182 validate_schema_with_value_error( 183 input_parsed, 184 task.input_json_schema, 185 "Input does not match task input schema.", 186 require_object=False, 187 ) 188 189 self._last_validated_input = self.input 190 return self 191 192 @model_validator(mode="after") 193 def validate_output_format(self, info: ValidationInfo) -> Self: 194 # Don't validate if loading from file (not new). Too slow. 195 # Note: we still validate if editing a loaded model's output. 196 if self.loading_from_file(info): 197 # Consider loading an existing model as validated. 198 self._last_validated_output = self.output.output if self.output else None 199 return self 200 201 # Skip output validation when the run is waiting for tool call results. 202 # The output field is empty/partial in this state. 203 if self.is_toolcall_pending: 204 self._last_validated_output = self.output.output if self.output else None 205 return self 206 207 # Don't validate unless output has changed since last validation. 208 # The validator is slow and costly, don't want it running when setting other fields. 209 if ( 210 hasattr(self, "_last_validated_output") 211 and self.output is not None 212 and self.output.output == self._last_validated_output 213 ): 214 return self 215 216 task = self.parent_task() 217 if task is None: 218 return self 219 220 self.output.validate_output_format(task) 221 self._last_validated_output = self.output.output if self.output else None 222 return self 223 224 @model_validator(mode="after") 225 def validate_repaired_output(self) -> Self: 226 if self.repaired_output is not None: 227 if self.repaired_output.rating is not None: 228 raise ValueError( 229 "Repaired output rating must be None. Repaired outputs are assumed to have a perfect rating, as they have been fixed." 230 ) 231 232 task = self.parent_task() 233 if ( 234 task is not None 235 and self.repaired_output.output is not None 236 and task.output_json_schema is not None 237 ): 238 try: 239 output_parsed = json.loads(self.repaired_output.output) 240 except json.JSONDecodeError: 241 raise ValueError("Repaired output is not a valid JSON object") 242 243 validate_schema_with_value_error( 244 output_parsed, 245 task.output_json_schema, 246 "Repaired output does not match task output schema.", 247 ) 248 249 if self.repair_instructions is None and self.repaired_output is not None: 250 raise ValueError( 251 "Repair instructions are required if providing a repaired output." 252 ) 253 if self.repair_instructions is not None and self.repaired_output is None: 254 raise ValueError( 255 "A repaired output is required if providing repair instructions." 256 ) 257 258 return self 259 260 @model_validator(mode="after") 261 def validate_input_source(self, info: ValidationInfo) -> Self: 262 # On strict mode and not loaded from file, we validate input_source is not None. 263 # We want to be able to load any data, even if it's not perfect. But we want to create perfect data when adding new data. 264 if not strict_mode(): 265 return self 266 if self.loaded_from_file(info): 267 return self 268 if self.input_source is None: 269 raise ValueError("input_source is required when strict mode is enabled") 270 return self 271 272 @model_validator(mode="after") 273 def validate_tags(self) -> Self: 274 for tag in self.tags: 275 if not tag: 276 raise ValueError("Tags cannot be empty strings") 277 if " " in tag: 278 raise ValueError("Tags cannot contain spaces. Try underscores.") 279 280 return self
Represents a single execution of a Task.
Contains the input used, its source, the output produced, and optional repair information if the output needed correction.
123 @property 124 def is_toolcall_pending(self) -> bool: 125 """True if the trace ends with an assistant message awaiting client tool execution.""" 126 return trace_has_pending_client_tool_calls(self.trace)
True if the trace ends with an assistant message awaiting client tool execution.
128 def thinking_training_data(self) -> str | None: 129 """ 130 Get the thinking training data from the task run. 131 """ 132 if self.intermediate_outputs is None: 133 return None 134 return self.intermediate_outputs.get( 135 "reasoning" 136 ) or self.intermediate_outputs.get("chain_of_thought")
Get the thinking training data from the task run.
138 def has_thinking_training_data(self) -> bool: 139 """ 140 Does this run have thinking data that we can use to train a thinking model? 141 """ 142 return self.thinking_training_data() is not None
Does this run have thinking data that we can use to train a thinking model?
838 def child_method(self, readonly: bool = False) -> list[child_class]: # type: ignore[invalid-type-form] 839 return child_class.all_children_of_parent_path(self.path, readonly=readonly)
The type of the None singleton.
153 @model_validator(mode="after") 154 def validate_input_format(self, info: ValidationInfo) -> Self: 155 # Don't validate if loading from file (not new). Too slow. 156 # We don't allow changing task schema, so this is redundant validation. 157 # Note: we still validate if editing a loaded model 158 if self.loading_from_file(info): 159 # Consider loading an existing model as validated. 160 self._last_validated_input = self.input 161 return self 162 163 # Don't validate if input has not changed. Too slow to run this every time. 164 if ( 165 hasattr(self, "_last_validated_input") 166 and self.input == self._last_validated_input 167 ): 168 return self 169 170 task = self.parent_task() 171 if task is None: 172 # don't validate this relationship until we have a path or parent. Give them time to build it (but will catch it before saving) 173 return self 174 175 # validate input 176 if task.input_json_schema is not None: 177 try: 178 input_parsed = json.loads(self.input) 179 except json.JSONDecodeError: 180 raise ValueError("Input is not a valid JSON object") 181 182 validate_schema_with_value_error( 183 input_parsed, 184 task.input_json_schema, 185 "Input does not match task input schema.", 186 require_object=False, 187 ) 188 189 self._last_validated_input = self.input 190 return self
192 @model_validator(mode="after") 193 def validate_output_format(self, info: ValidationInfo) -> Self: 194 # Don't validate if loading from file (not new). Too slow. 195 # Note: we still validate if editing a loaded model's output. 196 if self.loading_from_file(info): 197 # Consider loading an existing model as validated. 198 self._last_validated_output = self.output.output if self.output else None 199 return self 200 201 # Skip output validation when the run is waiting for tool call results. 202 # The output field is empty/partial in this state. 203 if self.is_toolcall_pending: 204 self._last_validated_output = self.output.output if self.output else None 205 return self 206 207 # Don't validate unless output has changed since last validation. 208 # The validator is slow and costly, don't want it running when setting other fields. 209 if ( 210 hasattr(self, "_last_validated_output") 211 and self.output is not None 212 and self.output.output == self._last_validated_output 213 ): 214 return self 215 216 task = self.parent_task() 217 if task is None: 218 return self 219 220 self.output.validate_output_format(task) 221 self._last_validated_output = self.output.output if self.output else None 222 return self
224 @model_validator(mode="after") 225 def validate_repaired_output(self) -> Self: 226 if self.repaired_output is not None: 227 if self.repaired_output.rating is not None: 228 raise ValueError( 229 "Repaired output rating must be None. Repaired outputs are assumed to have a perfect rating, as they have been fixed." 230 ) 231 232 task = self.parent_task() 233 if ( 234 task is not None 235 and self.repaired_output.output is not None 236 and task.output_json_schema is not None 237 ): 238 try: 239 output_parsed = json.loads(self.repaired_output.output) 240 except json.JSONDecodeError: 241 raise ValueError("Repaired output is not a valid JSON object") 242 243 validate_schema_with_value_error( 244 output_parsed, 245 task.output_json_schema, 246 "Repaired output does not match task output schema.", 247 ) 248 249 if self.repair_instructions is None and self.repaired_output is not None: 250 raise ValueError( 251 "Repair instructions are required if providing a repaired output." 252 ) 253 if self.repair_instructions is not None and self.repaired_output is None: 254 raise ValueError( 255 "A repaired output is required if providing repair instructions." 256 ) 257 258 return self
260 @model_validator(mode="after") 261 def validate_input_source(self, info: ValidationInfo) -> Self: 262 # On strict mode and not loaded from file, we validate input_source is not None. 263 # We want to be able to load any data, even if it's not perfect. But we want to create perfect data when adding new data. 264 if not strict_mode(): 265 return self 266 if self.loaded_from_file(info): 267 return self 268 if self.input_source is None: 269 raise ValueError("input_source is required when strict mode is enabled") 270 return self
The type of the None singleton.
Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
365def init_private_attributes(self: BaseModel, context: Any, /) -> None: 366 """This function is meant to behave like a BaseModel method to initialize private attributes. 367 368 It takes context as an argument since that's what pydantic-core passes when calling it. 369 370 Args: 371 self: The BaseModel instance. 372 context: The context. 373 """ 374 if getattr(self, '__pydantic_private__', None) is None: 375 pydantic_private = {} 376 for name, private_attr in self.__private_attributes__.items(): 377 # Avoid needlessly creating a new dict for the validated data: 378 if private_attr.default_factory_takes_validated_data: 379 default = private_attr.get_default( 380 call_default_factory=True, validated_data={**self.__dict__, **pydantic_private} 381 ) 382 else: 383 default = private_attr.get_default(call_default_factory=True) 384 if default is not PydanticUndefined: 385 pydantic_private[name] = default 386 object_setattr(self, '__pydantic_private__', pydantic_private)
This function is meant to behave like a BaseModel method to initialize private attributes.
It takes context as an argument since that's what pydantic-core passes when calling it.
Args: self: The BaseModel instance. context: The context.
151class Usage(MessageUsage): 152 """Token usage, cost, and aggregate LLM latency for a per-run accumulator. 153 154 Extends :class:`MessageUsage` with ``total_llm_latency_ms``, which is 155 only meaningful while a single run is in flight (its model calls run 156 sequentially in real time). For per-message records and full-trace 157 sums use :class:`MessageUsage` — those values would mix latencies 158 from different points in time, so the field doesn't apply. 159 """ 160 161 total_llm_latency_ms: int | None = Field( 162 default=None, 163 description="Total time spent waiting on LLM API calls in milliseconds. Sum of per-call latencies, excludes tool execution time.", 164 ge=0, 165 ) 166 167 def __add__(self, other: "MessageUsage | Usage") -> "Usage": 168 """Add Usage to either Usage or MessageUsage. 169 170 Token / cost fields sum the same way as :meth:`MessageUsage.__add__`. 171 172 ``Usage + Usage`` sums both ``total_llm_latency_ms`` values 173 (None-graceful). ``Usage + MessageUsage`` carries ``self``'s 174 ``total_llm_latency_ms`` through unchanged — the right-hand side 175 has no latency to contribute. 176 177 Always returns a :class:`Usage` so chained ``usage += msg_usage`` 178 keeps the latency on the accumulator. 179 """ 180 if not isinstance(other, MessageUsage): 181 raise TypeError(f"Cannot add Usage with {type(other).__name__}") 182 183 other_latency = other.total_llm_latency_ms if isinstance(other, Usage) else None 184 185 return Usage( 186 input_tokens=_add_optional_int(self.input_tokens, other.input_tokens), 187 output_tokens=_add_optional_int(self.output_tokens, other.output_tokens), 188 total_tokens=_add_optional_int(self.total_tokens, other.total_tokens), 189 cost=_add_optional_float(self.cost, other.cost), 190 cached_tokens=_add_optional_int(self.cached_tokens, other.cached_tokens), 191 total_llm_latency_ms=_add_optional_int( 192 self.total_llm_latency_ms, other_latency 193 ), 194 )
Token usage, cost, and aggregate LLM latency for a per-run accumulator.
Extends MessageUsage with total_llm_latency_ms, which is
only meaningful while a single run is in flight (its model calls run
sequentially in real time). For per-message records and full-trace
sums use MessageUsage — those values would mix latencies
from different points in time, so the field doesn't apply.