Skip to content
20 changes: 20 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,23 @@ repos:
hooks:
- id: end-of-file-fixer
- id: trailing-whitespace

# Local hooks mirror the CI checks (.github/workflows/ci.yml) so type and
# test failures surface at commit time instead of in CI. They invoke the
# project venv's interpreter (.venv/bin/python) directly because mypy and
# pytest need the installed dependencies, which an isolated pre-commit env
# would not have, and so they work whether or not the venv is activated.
- repo: local
hooks:
- id: mypy
name: mypy (src/prkit)
entry: .venv/bin/python -m mypy src/prkit
language: system
files: ^src/prkit/
pass_filenames: false
- id: pytest
name: pytest (tests/prkit)
entry: .venv/bin/python -m pytest tests/prkit -q
language: system
files: ^(src/prkit|tests/prkit)/
pass_filenames: false
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ Production releases follow semantic versioning. TestPyPI validation builds use P

### Added

- **Batch API support across OpenAI, Anthropic, and Gemini** — `BaseModelClient` gains a synchronous batch job lifecycle (`submit_batch` → `poll_batch` → `retrieve_batch_results`) plus a free-text request builder `build_batch_request(...)` that mirrors `response()` (same `input`/`instructions` handling, no structured output), complementing the existing structured `build_batch_structured_request`. New provider-agnostic types `BatchState`, `BatchStatus`, `BatchItemStatus`, and `BatchResult` (in `prkit.core.model_clients.batch_types`) normalize each provider's status enum and per-request results. Each provider's request-body construction is now shared between `response()` and the batch builders (`_build_responses_body` / `_build_messages_params`) to prevent drift. OpenAI o-family models drop `temperature` at build time. Unsupported providers raise `NotImplementedError`. Batch processing runs asynchronously at ~50% of synchronous cost. Gemini batches are submitted as an uploaded keyed JSONL file (via the File API, `src=<file>`) rather than as inline requests, so results come back as documented keyed JSONL (`{"key": ..., "response": {...}}`) and correlate reliably to each request — inline responses carry no per-request key and cannot be correlated.
- **`OpenAIModel` custom endpoint support** — new keyword-only constructor params `base_url`, `api_key`, and `api_key_env` allow routing to any proxy or gateway that implements the OpenAI Responses API (`POST /v1/responses`) with an explicit key or key from a named environment variable. Backward-compatible: omitting all three preserves existing `OPENAI_API_KEY` + default endpoint behaviour.
- **`OllamaModel` explicit auth params** — new keyword-only constructor params `api_key` and `api_key_env` forward a `Bearer` token as the `Authorization` header to `ollama.Client`, providing API-key parity with other providers. Works for cloud endpoints (e.g. `base_url="https://ollama.com"`).
- **Remote-safe Ollama preflight** — when `base_url` or `OLLAMA_HOST` points to a non-local host, a failed startup connectivity check now emits a warning instead of raising `ConnectionError`; precise errors surface at `chat()` call time.
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ dataset = DatasetHub.load("physreason", variant="full", split="test")
# Run inference with the unified model client (core component)
client = create_model_client("gpt-4.1-mini")
for problem in dataset[:3]:
print(client.chat(problem.question)[:200)
print(client.solve_physics_problem(problem)[:200])
```

The same pattern works across different datasets and model providers—swap the dataset name or model identifier.
Expand Down
2 changes: 1 addition & 1 deletion RELEASE_NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ from prkit.core.model_clients import create_model_client
dataset = DatasetHub.load("physreason", variant="full", split="test")
client = create_model_client("gpt-4.1-mini")
for problem in dataset[:3]:
print(client.chat(problem.question)[:200])
print(client.solve_physics_problem(problem)[:200])
```

### Requirements
Expand Down
4 changes: 2 additions & 2 deletions cookbooks/inference_deepseek.py
Original file line number Diff line number Diff line change
Expand Up @@ -135,8 +135,8 @@ def run_inference(model_name: str, prompt: str, image_path: str = None):

try:
# Note: image_paths parameter is accepted but images will be ignored
response = client.chat(
user_prompt=prompt, image_paths=[image_path] if image_path else None
response = client.response(
input=prompt, image_paths=[image_path] if image_path else None
)

logger.info("\n" + "=" * 60)
Expand Down
4 changes: 2 additions & 2 deletions cookbooks/inference_gemini.py
Original file line number Diff line number Diff line change
Expand Up @@ -151,8 +151,8 @@ def run_inference(model_name: str, prompt: str, image_path: str = None):

try:
# Note: image_paths parameter is accepted but images will be ignored
response = client.chat(
user_prompt=prompt, image_paths=[image_path] if image_path else None
response = client.response(
input=prompt, image_paths=[image_path] if image_path else None
)

logger.info("\n" + "=" * 60)
Expand Down
2 changes: 1 addition & 1 deletion cookbooks/inference_ollama.py
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ def run_inference(model_name: str, prompt: str, image_path: str = None):
logger.info(f" Images: {len(image_paths)} image(s)")

try:
response = client.chat(user_prompt=prompt, image_paths=image_paths)
response = client.response(input=prompt, image_paths=image_paths)

logger.info("\n" + "=" * 60)
logger.info("Response:")
Expand Down
2 changes: 1 addition & 1 deletion cookbooks/inference_openai.py
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ def run_inference(model_name: str, prompt: str, image_path: str = None):
logger.info(f" Images: {len(image_paths)} image(s)")

try:
response = client.chat(user_prompt=prompt, image_paths=image_paths)
response = client.response(input=prompt, image_paths=image_paths)

logger.info("\n" + "=" * 60)
logger.info("Response:")
Expand Down
4 changes: 2 additions & 2 deletions cookbooks/inference_seephys_structured.py
Original file line number Diff line number Diff line change
Expand Up @@ -172,8 +172,8 @@ def run_inference(
if client.provider == "google":
extra_kwargs["max_output_tokens"] = 65535

response_text = client.chat(
user_prompt=full_prompt,
response_text = client.response(
input=full_prompt,
image_paths=image_paths,
response_format=ReasonAndAnswer,
**extra_kwargs,
Expand Down
2 changes: 1 addition & 1 deletion cookbooks/inference_single_with_answer_tags.py
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,7 @@ def run_single_inference(
for p in image_paths:
if not Path(p).exists():
raise FileNotFoundError(f"Image file not found: {p}")
response = client.chat(user_prompt=full_prompt, image_paths=image_paths)
response = client.response(input=full_prompt, image_paths=image_paths)
result["model_response"] = response
result["model_answer"] = parse_answer_from_response(response)
except FileNotFoundError as e:
Expand Down
42 changes: 38 additions & 4 deletions docs/CORE.md
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,11 @@ Utility components provide supporting infrastructure used across the toolkit. Th

### Model Client (BaseModelClient, create_model_client)

Unified interface for running inference across multiple providers (LLMs and VLMs). Subclasses implement `chat(user_prompt: str, image_paths: Optional[List[str]] = None)`. Use `create_model_client(model: str)` to get the right implementation based on the model name. Vision-capable providers consume `image_paths`; others ignore images with a warning.
Unified interface for running inference across multiple providers (LLMs and VLMs). Subclasses implement `response(input: str, image_paths: Optional[List[str]] = None, *, instructions: Optional[str] = None)`, mirroring OpenAI's `client.responses.create` (`input` is the user prompt, `instructions` is the system prompt). Use `create_model_client(model: str)` to get the right implementation based on the model name. Vision-capable providers consume `image_paths`; others ignore images with a warning.

When `instructions` is omitted, every provider **except OpenAI** falls back to a short default system prompt, `DEFAULT_INSTRUCTIONS` (`"You are a physics expert. …"`); OpenAI sends `input` alone. Pass `instructions=""` to suppress the system prompt entirely. The legacy `chat(user_prompt=...)` method still works as a deprecated alias for `response(input=...)` but emits a `DeprecationWarning`.

For physics problems specifically, `solve_physics_problem()` builds the prompt and attaches images for you (see below).

**Supported providers** (selected by model name pattern):

Expand All @@ -185,14 +189,44 @@ Unified interface for running inference across multiple providers (LLMs and VLMs
from prkit.core.model_clients import create_model_client

client = create_model_client("gpt-4.1-mini")
print(client.chat("State Newton's second law in one sentence."))
print(client.response("State Newton's second law in one sentence."))

# Vision (optional)
text = client.chat(
text = client.response(
"Solve the problem shown in the image and return only the final answer.",
image_paths=["/absolute/path/to/problem.png"],
)
print(text)

# Custom system prompt (sent as the provider's system/instructions field)
print(client.response("List three SI base units.", instructions="Answer tersely."))
```

#### Asking a physics problem (`solve_physics_problem`)

`solve_physics_problem()` is a convenience that builds the prompt and attaches any
images, then calls `response()`. The input is dispatched on type: a plain `str`
question, a `PhysicsProblem` (parsed into prompt text plus its `image_path`
images), or — in a future release — a `PhysicsQuestionSemantics`. The
`output_mode` selects the answer form; only `PhysicsOutputMode.ANSWER_TEXT` is
implemented today.

```python
from prkit.core.domain import PhysicsProblem
from prkit.core.model_clients import create_model_client

client = create_model_client("gpt-4.1-mini")

# From a plain question string
print(client.solve_physics_problem("State Newton's second law in one sentence."))

# From a PhysicsProblem (question + options + images are formatted for you)
problem = PhysicsProblem(
problem_id="p1",
question="A 2 kg block accelerates at 3 m/s^2. What net force acts on it?",
problem_type="OE",
)
print(client.solve_physics_problem(problem))
```

#### Custom OpenAI Responses-API endpoints
Expand Down Expand Up @@ -248,7 +282,7 @@ client = OllamaModel("llama3:70b-cloud", base_url="https://ollama.com")
Key-resolution precedence: explicit `api_key` → `api_key_env` env lookup → library
auto-reads `OLLAMA_API_KEY`. For remote hosts (`base_url` pointing to a non-localhost
address) a failed startup preflight emits a warning instead of raising `ConnectionError`;
precise errors surface at `chat()` call time.
precise errors surface at `response()` call time.

#### Registering additional providers

Expand Down
32 changes: 19 additions & 13 deletions src/prkit/annotation/workers/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from abc import ABC, abstractmethod
from typing import Any

from prkit.core.model_clients import create_model_client
from prkit.core.model_clients import DEFAULT_INSTRUCTIONS, create_model_client
from prkit.core.model_clients.base import BaseModelClient


Expand Down Expand Up @@ -51,19 +51,17 @@ def _call_llm_structured(self, prompt: str, response_format: Any) -> Any:
or None if call fails
"""
try:
full_prompt = (
"You are a physics expert. Provide accurate, detailed analysis of physics problems. "
"Always respond with valid JSON in the exact format requested.\n\n"
f"{prompt}"
)
if hasattr(response_format, "model_validate") and (
"chat_structured" in type(self.llm_client).__dict__
or isinstance(self.llm_client, BaseModelClient)
):
# The physics-expert role is sent as `instructions`; the JSON
# contract is added by chat_structured's structured-output suffix.
result = self.llm_client.chat_structured(
full_prompt,
prompt,
response_model=response_format,
structured_policy="best_effort",
instructions=DEFAULT_INSTRUCTIONS,
)
if result.parsed is not None:
return result.parsed
Expand All @@ -74,7 +72,14 @@ def _call_llm_structured(self, prompt: str, response_format: Any) -> Any:
return response_format(**response_dict)
return None

response_text = self.llm_client.chat(full_prompt)
# Fallback for clients without structured output: ask for JSON inline.
json_prompt = (
f"{prompt}\n\n"
"Always respond with valid JSON in the exact format requested."
)
response_text = self.llm_client.response(
input=json_prompt, instructions=DEFAULT_INSTRUCTIONS
)
if response_text:
import json

Expand All @@ -98,12 +103,13 @@ def _call_llm(self, prompt: str) -> str:
Response text from LLM, or empty JSON string if call fails
"""
try:
full_prompt = (
"You are a physics expert. Provide accurate, detailed analysis of physics problems. "
"Always respond with valid JSON in the exact format requested.\n\n"
f"{prompt}"
json_prompt = (
f"{prompt}\n\n"
"Always respond with valid JSON in the exact format requested."
)
return self.llm_client.chat(full_prompt).strip()
return self.llm_client.response(
input=json_prompt, instructions=DEFAULT_INSTRUCTIONS
).strip()
except Exception as e:
print(f"Error calling LLM API: {e}")
return "{}"
20 changes: 19 additions & 1 deletion src/prkit/core/model_clients/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,34 @@

from typing import Any

from .base import BaseModelClient
from .base import DEFAULT_INSTRUCTIONS, BaseModelClient
from .batch_types import (
TERMINAL_STATES,
BatchItemStatus,
BatchResult,
BatchState,
BatchStatus,
)
from .factory import ProviderRule, create_model_client, register_model_client
from .modes import PhysicsOutputMode
from .prompts import build_plain_question_prompt, format_problem_context

create_llm_client = create_model_client

__all__ = [
"BaseModelClient",
"BatchItemStatus",
"BatchResult",
"BatchState",
"BatchStatus",
"DEFAULT_INSTRUCTIONS",
"PhysicsOutputMode",
"ProviderRule",
"TERMINAL_STATES",
"build_plain_question_prompt",
"create_model_client",
"create_llm_client",
"format_problem_context",
"register_model_client",
"AnthropicModel",
"DashscopeModel",
Expand Down
Loading
Loading