Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,10 +42,10 @@ jobs:
matrix:
include:
- sdk-versions: minimum
openai: openai==2.34.0
openai: openai==3.42.0
anthropic: anthropic==0.83.0
- sdk-versions: latest
openai: openai>=2.34.0,<4.0.0
openai: openai>=3.42.0,<4.0.0
anthropic: anthropic>=0.83.0,<2.0.0
name: Provider SDKs (${{ matrix.sdk-versions }})
steps:
Expand Down
11 changes: 9 additions & 2 deletions examples/models/openai/decisions_images.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,11 @@ class DeliveryQuestions(pydantic.BaseModel):


class DeliveryAnswers(pydantic.BaseModel):
damaged: ai.ops.experimental.PredicateAnswer
# OpenAI can refuse a question. This field accepts a refusal; a refusal
# for the other fields raises ValueError.
damaged: (
ai.ops.experimental.PredicateAnswer | ai.ops.experimental.RefusalAnswer
)
condition: ai.ops.experimental.ScoreAnswer
matches_reference: ai.ops.experimental.PredicateAnswer

Expand Down Expand Up @@ -77,7 +81,10 @@ async def main() -> None:
)

answers = result.value
print("damaged:", f"{answers.damaged.probability:.0%}")
if isinstance(answers.damaged, ai.ops.experimental.RefusalAnswer):
print("damaged: refused")
else:
print("damaged:", f"{answers.damaged.probability:.0%}")
print("condition:", answers.condition.score)
print("matches reference:", f"{answers.matches_reference.probability:.0%}")
print("usage:", result.usage)
Expand Down
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ dependencies = [
[project.optional-dependencies]
anthropic = ["anthropic>=0.83.0,<2.0.0"]
mcp = ["mcp>=2.0.0,<3.0.0"]
openai = ["openai>=2.34.0,<4.0.0"]
openai = ["openai>=3.42.0,<4.0.0"]
otel = [
"opentelemetry-exporter-otlp-proto-http>=1.30",
"opentelemetry-sdk>=1.30",
Expand Down Expand Up @@ -75,7 +75,7 @@ dev = [
"pytest-asyncio>=0.24",
"rich>=14.2.0",
"mypy~=2.1.0",
"openai>=2.14.0",
"openai>=3.42.0",
"ruff~=0.8.0",
"async-solipsism>=0.9",
"ty~=0.0.37",
Expand Down
2 changes: 2 additions & 0 deletions src/ai/ops/experimental/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
PredicateAnswer,
PredicateCriteria,
PredicateQuestion,
RefusalAnswer,
ScoreAnswer,
ScoreQuestion,
decide,
Expand Down Expand Up @@ -56,6 +57,7 @@ def __getattr__(name: str) -> Any:
"PredicateAnswer",
"PredicateCriteria",
"PredicateQuestion",
"RefusalAnswer",
"ScoreAnswer",
"ScoreQuestion",
"decide",
Expand Down
51 changes: 46 additions & 5 deletions src/ai/ops/experimental/decisions.py
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,18 @@ def noul(self) -> float:
return self.probability


class RefusalAnswer(pydantic.BaseModel):
"""The model declined to answer this question.

Annotate an output field as ``XAnswer | RefusalAnswer`` to accept a
refusal for that question.
"""

type: Literal["refusal"] = "refusal"

model_config = pydantic.ConfigDict(frozen=True)


@dataclasses.dataclass(frozen=True, kw_only=True)
class DecideParams:
"""Parameters for decisions."""
Expand All @@ -279,7 +291,7 @@ class DecideParams:


type _Question = ChoiceQuestion | ScoreQuestion | PredicateQuestion
type _Answer = ChoiceAnswer | ScoreAnswer | PredicateAnswer
type _Answer = ChoiceAnswer | ScoreAnswer | PredicateAnswer | RefusalAnswer

_QUESTION_TYPES = (ChoiceQuestion, ScoreQuestion, PredicateQuestion)
_ANSWER_TYPES: dict[type[_Question], type[pydantic.BaseModel]] = {
Expand Down Expand Up @@ -334,6 +346,11 @@ async def decide(
``{"photo": ai.file_part(data), "note": "..."}``. Providers reject files
they cannot send.

Some providers can refuse a question. A refusal is a
:class:`RefusalAnswer`. In typed mode, annotate the output field as
``XAnswer | RefusalAnswer`` to accept it; otherwise a refusal raises
``ValueError``.

Experimental: not part of the stable API, may change or be removed.
"""
# Validate the shared state before dispatching it to a provider.
Expand Down Expand Up @@ -372,21 +389,29 @@ async def decide(

# Normalize either public input form to the mapping expected by providers.
normalized: dict[str, _Question] = {}
# Questions that accept a refusal. In dynamic mode, all of them do.
refusable: set[str] = set()
for name, question in values:
if not isinstance(name, str):
raise TypeError("question IDs must be strings")
if not isinstance(question, _QUESTION_TYPES):
raise TypeError(f"question field {name!r} must contain a question")

# In typed mode, verify each question has its corresponding answer type.
# In typed mode, verify each question has its corresponding answer
# type, optionally in a union with RefusalAnswer.
if answer_fields is not None:
expected = _ANSWER_TYPES[type(question)]
actual = answer_fields[name].annotation
if actual is not expected:
if actual == expected | RefusalAnswer:
refusable.add(name)
elif actual is not expected:
raise TypeError(
f"output field {name!r} must be annotated as "
f"{expected.__name__}"
f"{expected.__name__} or "
f"{expected.__name__} | RefusalAnswer"
)
else:
refusable.add(name)
normalized[name] = question

# Start telemetry after local validation, immediately around provider work.
Expand All @@ -410,6 +435,20 @@ async def decide(
if raw_item.value.keys() != normalized.keys():
raise ValueError("answer fields must match question fields")

# A refusal needs an output field that accepts it.
refused = {
name
for name, raw_answer in raw_item.value.items()
if isinstance(raw_answer, Mapping)
and raw_answer.get("type") == "refusal"
}
unaccepted = sorted(refused - refusable)
if unaccepted:
raise ValueError(
f"the model refused question {unaccepted[0]!r}; annotate its "
"output field with '| RefusalAnswer' to accept refusals"
)

if output_type is not None:
# Typed mode validates the complete response into the user's model.
value: pydantic.BaseModel | dict[str, _Answer] = (
Expand All @@ -421,7 +460,9 @@ async def decide(
answers: dict[str, _Answer] = {}
for name, question in normalized.items():
raw_answer = raw_item.value[name]
if isinstance(question, ChoiceQuestion):
if name in refused:
answers[name] = RefusalAnswer.model_validate(raw_answer)
elif isinstance(question, ChoiceQuestion):
answers[name] = ChoiceAnswer.model_validate(raw_answer)
elif isinstance(question, ScoreQuestion):
answers[name] = ScoreAnswer.model_validate(raw_answer)
Expand Down
17 changes: 17 additions & 0 deletions src/ai/providers/openai/_sdk.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from collections.abc import Callable

import openai
from openai.types import decision


class OpenAISDK(Protocol):
Expand All @@ -26,6 +27,11 @@ class OpenAIPydantic(Protocol):
to_strict_json_schema: Callable[[Any], dict[str, Any]]


class OpenAIDecisionTypes(Protocol):
AnswerAnswerResourceChoice: type[decision.AnswerAnswerResourceChoice]
AnswerAnswerResourceScore: type[decision.AnswerAnswerResourceScore]


def import_sdk(*, provider: str = "openai") -> OpenAISDK:
return cast(
"OpenAISDK",
Expand All @@ -44,3 +50,14 @@ def import_pydantic(*, provider: str = "openai") -> OpenAIPydantic:
extra="openai",
),
)


def import_decision_types(*, provider: str = "openai") -> OpenAIDecisionTypes:
return cast(
"OpenAIDecisionTypes",
_optional.import_optional_sdk(
"openai.types.decision",
provider=provider,
extra="openai",
),
)
Loading
Loading