From 2a6fe826c62edaa400b741ca99a38ddc67bcc400 Mon Sep 17 00:00:00 2001 From: tomflenner Date: Mon, 19 Oct 2026 10:29:33 +0200 Subject: [PATCH 1/8] feat(ai): add keyed credit reporting and best-effort meters Signed-off-by: tomflenner --- openapi/coverage.yaml | 7 +- scripts/unasync.py | 4 + src/kaitencloud/__init__.py | 13 +- src/kaitencloud/_ai.py | 314 +++++++++ src/kaitencloud/_ai_models.py | 76 ++ src/kaitencloud/_async/_http.py | 51 +- src/kaitencloud/_async/ai.py | 238 +++++++ src/kaitencloud/_async/resources/instances.py | 146 +++- src/kaitencloud/_exceptions.py | 74 ++ src/kaitencloud/_sync/_http.py | 51 +- src/kaitencloud/_sync/ai.py | 240 +++++++ src/kaitencloud/_sync/resources/instances.py | 146 +++- src/kaitencloud/ai.py | 278 ++++++++ src/kaitencloud/types/__init__.py | 8 + src/kaitencloud/types/_params.py | 45 ++ tests/ai_fakes.py | 109 +++ tests/test_ai.py | 661 ++++++++++++++++++ tests/test_contract.py | 53 +- 18 files changed, 2491 insertions(+), 23 deletions(-) create mode 100644 src/kaitencloud/_ai.py create mode 100644 src/kaitencloud/_ai_models.py create mode 100644 src/kaitencloud/_async/ai.py create mode 100644 src/kaitencloud/_sync/ai.py create mode 100644 src/kaitencloud/ai.py create mode 100644 tests/ai_fakes.py create mode 100644 tests/test_ai.py diff --git a/openapi/coverage.yaml b/openapi/coverage.yaml index 273d5e0..19fef99 100644 --- a/openapi/coverage.yaml +++ b/openapi/coverage.yaml @@ -86,7 +86,12 @@ core: getAuditTrails: instances.list_audit_trails getEntitlementsUsageMetrics: instances.list_usage getEntitlementUsageMetrics: instances.get_usage - reportEntitlementUsageMetric: instances.report_usage + # One operation, two report shapes: a number, or one phase of an AI call (kaitencloud.ai + # reserves, runs and commits through the last two). + reportEntitlementUsageMetric: + - instances.report_usage + - instances.reserve_ai_usage + - instances.commit_ai_usage get-instance-integration: instances.get_integration create-instance-integration: instances.create_integration update-instance-integration: instances.update_integration diff --git a/scripts/unasync.py b/scripts/unasync.py index cb0c32b..6a97623 100644 --- a/scripts/unasync.py +++ b/scripts/unasync.py @@ -41,6 +41,10 @@ (re.compile(r"\b__aenter__\b"), "__enter__"), (re.compile(r"\b__aexit__\b"), "__exit__"), (re.compile(r"\baclose\b"), "close"), + (re.compile(r"\b__aiter__\b"), "__iter__"), + (re.compile(r"\b__anext__\b"), "__next__"), + (re.compile(r"\bStopAsyncIteration\b"), "StopIteration"), + (re.compile(r"\bAwaitable\[([\w.]+)\]"), r"\1"), (re.compile(r"\basync_(\w+)"), r"\1"), (re.compile(r"\b_Async(?=[A-Z])"), "_"), (re.compile(r"\bAsync(?=[A-Z])"), ""), diff --git a/src/kaitencloud/__init__.py b/src/kaitencloud/__init__.py index dd539e5..6b2231a 100644 --- a/src/kaitencloud/__init__.py +++ b/src/kaitencloud/__init__.py @@ -17,10 +17,11 @@ import importlib from typing import TYPE_CHECKING, Any -from . import targeting, types, usage +from . import ai, targeting, types, usage from ._async.client import AsyncKaitenClient, AsyncKaitenPlatformClient from ._constants import DEFAULT_MAX_RETRIES, DEFAULT_TIMEOUT from ._exceptions import ( + AiUsageUnsupportedError, APIConnectionError, APIResponseValidationError, APIStatusError, @@ -29,14 +30,18 @@ BadRequestError, ConflictError, CredentialError, + InsufficientCreditsError, InternalServerError, KaitenError, + ModelNotAuthorizedError, NotFoundError, PaginationError, PermissionDeniedError, RateLimitError, ServiceUnavailableError, ThresholdExceededError, + TransactionIdReusedError, + UnknownModelError, UnprocessableEntityError, WebhookPayloadError, WebhookVerificationError, @@ -59,17 +64,20 @@ "APIResponseValidationError", "APIStatusError", "APITimeoutError", + "AiUsageUnsupportedError", "AsyncKaitenClient", "AsyncKaitenPlatformClient", "AuthenticationError", "BadRequestError", "ConflictError", "CredentialError", + "InsufficientCreditsError", "InternalServerError", "KaitenClient", "KaitenError", "KaitenModel", "KaitenPlatformClient", + "ModelNotAuthorizedError", "NotFoundError", "NotGiven", "PaginationError", @@ -77,10 +85,13 @@ "RateLimitError", "ServiceUnavailableError", "ThresholdExceededError", + "TransactionIdReusedError", + "UnknownModelError", "UnprocessableEntityError", "WebhookPayloadError", "WebhookVerificationError", "__version__", + "ai", "targeting", "types", "usage", diff --git a/src/kaitencloud/_ai.py b/src/kaitencloud/_ai.py new file mode 100644 index 0000000..e281d13 --- /dev/null +++ b/src/kaitencloud/_ai.py @@ -0,0 +1,314 @@ +# Copyright 2026 KAITEN INC +# SPDX-License-Identifier: Apache-2.0 + +"""The provider-agnostic pieces of AI usage metering, shared by the sync and async code. + +Nothing here talks to the network: transaction ids, the wire shape of a usage, the +normalisers that read a provider's usage object, the default estimator and the rule that +tells a provider error from a call that never left the machine. +""" + +from __future__ import annotations + +import json +import math +import os +import re +import time +import uuid +from collections.abc import Callable, Mapping +from dataclasses import dataclass, field +from decimal import Decimal, InvalidOperation +from typing import Any, Final + +import httpx + +from .types import AiRequestStatus, AiRequestType, AiUsageParam + +__all__ = [ + "DEFAULT_OUTPUT_TOKENS", + "AiRequest", + "Estimator", + "UsageOf", + "add_usage", + "ai_usage_body", + "check_transaction_id", + "default_estimator", + "from_anthropic", + "from_openai", + "new_transaction_id", + "request_status_of", + "ttl_from_timeout", +] + +AI_MAX_RETRIES: Final = 3 +"""How many times a reservation or a commit is retried, under the same key.""" + +AI_RETRY_STATUSES: Final = frozenset({500, 502, 503, 504}) +"""The statuses a reservation or a commit is retried on. Never a 4xx.""" + +MAX_TTL_SECONDS: Final = 3600 +"""The longest reservation the API grants by default (``KAITEN_AI_RESERVATION_MAX_TTL``).""" + +DEFAULT_OUTPUT_TOKENS: Final[Mapping[str, int]] = {"COMPLETION": 1024, "EMBEDDING": 0} +"""The output a request that sets no ceiling is expected to produce, per request type.""" + +_TRANSACTION_ID = re.compile(r"^[A-Za-z0-9._:-]{1,128}$") + +_USAGE_KEYS: Final = { + "input_tokens": "inputTokens", + "cache_read_input_tokens": "cacheReadInputTokens", + "cache_write_input_tokens": "cacheWriteInputTokens", + "output_tokens": "outputTokens", + "duration_ms": "durationMs", +} + + +def new_transaction_id() -> str: + """A fresh UUIDv7: time-ordered, so a pool's operations sort by when they started.""" + milliseconds = time.time_ns() // 1_000_000 + value = (milliseconds & (2**48 - 1)) << 80 | int.from_bytes(os.urandom(10), "big") + value = (value & ~(0xF << 76)) | (0x7 << 76) # version 7 + value = (value & ~(0x3 << 62)) | (0x2 << 62) # RFC 4122 variant + return str(uuid.UUID(int=value)) + + +def check_transaction_id(value: str) -> str: + """``value``, when the API accepts it as a transaction id; ``ValueError`` otherwise.""" + if not isinstance(value, str) or not _TRANSACTION_ID.match(value): + raise ValueError( + "a transaction id is 1 to 128 characters among letters, digits and . _ : -, " + f"got {value!r}" + ) + return value + + +def ai_usage_body(usage: AiUsageParam) -> dict[str, Any]: + """``usage`` as the API spells it, every count checked.""" + body: dict[str, Any] = {} + for key, wire in _USAGE_KEYS.items(): + if key in usage: + count = usage[key] # type: ignore[literal-required] + if isinstance(count, bool) or not isinstance(count, int) or count < 0: + raise ValueError( + f"usage {key} must be a whole number of zero or more, got {count!r}" + ) + body[wire] = count + if "units" in usage: + body["units"] = _decimal_string(usage["units"]) + if "unit_type" in usage: + body["unitType"] = usage["unit_type"] + unknown = set(usage) - set(_USAGE_KEYS) - {"units", "unit_type"} + if unknown: + raise ValueError(f"unknown usage fields: {', '.join(sorted(unknown))}") + return body + + +def _decimal_string(value: object) -> str: + if isinstance(value, bool) or not isinstance(value, (int, str, Decimal)): + raise ValueError(f"usage units must be an int, a str or a Decimal, got {value!r}") + try: + number = Decimal(value) + except InvalidOperation as error: + raise ValueError(f"usage units must be a decimal number, got {value!r}") from error + if not number.is_finite() or number < 0: + raise ValueError(f"usage units must be zero or more, got {value!r}") + return format(number, "f") + + +def add_usage(*usages: AiUsageParam | None) -> AiUsageParam: + """The sum of ``usages``, field by field: a stream's usage is the sum of its parts.""" + total: dict[str, Any] = {} + for usage in usages: + for key, value in dict(usage or {}).items(): + if key == "unit_type": + total[key] = value + elif key == "units": + total[key] = str(Decimal(str(total.get(key, 0))) + Decimal(str(value))) + else: + total[key] = total.get(key, 0) + value + return total # type: ignore[return-value] + + +# -- normalisers --------------------------------------------------------------------------- + + +def _get(source: Any, name: str) -> Any: + if source is None: + return None + if isinstance(source, Mapping): + return source.get(name) + return getattr(source, name, None) + + +def _count(source: Any, name: str) -> int: + value = _get(source, name) + return value if isinstance(value, int) and not isinstance(value, bool) else 0 + + +def from_openai(usage: Any) -> AiUsageParam: + """An OpenAI usage object -- Chat Completions, Responses, Embeddings, Images or Audio -- + as the disjoint fields of an :class:`~kaitencloud.types.AiUsageParam`. + + OpenAI counts cached input inside the input: ``prompt_tokens`` (or ``input_tokens``) + includes ``cached_tokens`` and ``cache_write_tokens``, so both are taken out of + ``input_tokens``. Reasoning tokens are already inside the output count. A transcription + billed by duration reports ``seconds``, kept as ``duration_ms``. Accepts the provider's + pydantic models and plain dictionaries alike; ``None`` gives an empty usage. + """ + if usage is None: + return {} + if _get(usage, "type") == "duration": + seconds = _get(usage, "seconds") or 0 + return {"duration_ms": max(round(float(seconds) * 1000), 0)} + + if _get(usage, "prompt_tokens") is not None: + total_input = _count(usage, "prompt_tokens") + details = _get(usage, "prompt_tokens_details") + output = _count(usage, "completion_tokens") + else: + total_input = _count(usage, "input_tokens") + details = _get(usage, "input_tokens_details") or _get(usage, "input_token_details") + output = _count(usage, "output_tokens") + cached = _count(details, "cached_tokens") + written = _count(details, "cache_write_tokens") + + normalised: AiUsageParam = {"input_tokens": max(total_input - cached - written, 0)} + if cached: + normalised["cache_read_input_tokens"] = cached + if written: + normalised["cache_write_input_tokens"] = written + normalised["output_tokens"] = output + return normalised + + +def from_anthropic(usage: Any) -> AiUsageParam: + """An Anthropic usage object as the disjoint fields of an + :class:`~kaitencloud.types.AiUsageParam`. + + Anthropic already counts cache reads and writes apart from ``input_tokens``, so the fields + map one to one. Accepts the provider's models and plain dictionaries alike -- the Claude + Agent SDK hands usage over as a dictionary -- and ``None`` gives an empty usage. + """ + if usage is None: + return {} + normalised: AiUsageParam = {"input_tokens": _count(usage, "input_tokens")} + if read := _count(usage, "cache_read_input_tokens"): + normalised["cache_read_input_tokens"] = read + if written := _count(usage, "cache_creation_input_tokens"): + normalised["cache_write_input_tokens"] = written + normalised["output_tokens"] = _count(usage, "output_tokens") + return normalised + + +# -- estimation ---------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class AiRequest: + """A call about to be made, as an estimator sees it.""" + + provider: str + """``"openai"``, ``"anthropic"``, or the agent SDK running the turn.""" + + operation: str + """The method called, e.g. ``"chat.completions.create"``.""" + + model: str + """The model asked for.""" + + request_type: AiRequestType + """What the call produces.""" + + params: Mapping[str, Any] = field(default_factory=dict) + """The call's keyword arguments, as given.""" + + +Estimator = Callable[[AiRequest], "AiUsageParam | None"] +"""Turns a request into the usage to reserve. ``None`` reserves without an estimate.""" + +UsageOf = Callable[[Any], "AiUsageParam | None"] +"""Reads the usage out of what a provider call returned.""" + +# The arguments that carry a request's prompt, per operation. Only their size is used. +_PROMPT_PARAMS: Final[Mapping[str, tuple[str, ...]]] = { + "chat.completions.create": ("messages", "tools"), + "responses.create": ("instructions", "input", "tools"), + "embeddings.create": ("input",), + "images.generate": ("prompt",), + "audio.speech.create": ("input",), + "messages.create": ("system", "messages", "tools"), + "messages.stream": ("system", "messages", "tools"), +} +_OUTPUT_CEILINGS: Final = ("max_completion_tokens", "max_output_tokens", "max_tokens") + + +def default_estimator(request: AiRequest) -> AiUsageParam: + """A heuristic of the usage ``request`` will report: never a dependency, never exact. + + Input is the serialized prompt's size in bytes over four. Output is the ceiling the + request sets (``max_completion_tokens``, ``max_output_tokens`` or ``max_tokens``), else + 1024 for a completion and 0 for an embedding. An image generation reserves its ``n`` + images. Over-estimating only inflates a reservation the commit settles; plug a real + tokenizer in through ``estimator=`` when that matters. + """ + params = request.params + if request.request_type == "IMAGE_GENERATION": + count = params.get("n") + return {"units": count if isinstance(count, int) and count > 0 else 1, "unit_type": "image"} + + estimate: AiUsageParam = {} + prompt = [params[name] for name in _PROMPT_PARAMS.get(request.operation, ()) if name in params] + if prompt: + size = len(json.dumps(prompt, default=str, ensure_ascii=False).encode("utf-8")) + estimate["input_tokens"] = math.ceil(size / 4) + ceiling = next( + (params[name] for name in _OUTPUT_CEILINGS if isinstance(params.get(name), int)), None + ) + if ceiling is not None: + estimate["output_tokens"] = max(ceiling, 0) + elif request.request_type in DEFAULT_OUTPUT_TOKENS: + estimate["output_tokens"] = DEFAULT_OUTPUT_TOKENS[request.request_type] + return estimate + + +def ttl_from_timeout(timeout: Any) -> int | None: + """A reservation lifetime matching a provider timeout, or ``None`` for the server's default. + + A number is seconds; an ``httpx``-style timeout object counts by its longest phase. + """ + if isinstance(timeout, bool): + return None + if isinstance(timeout, (int, float)): + seconds: float | None = float(timeout) + else: + phases = [getattr(timeout, name, None) for name in ("connect", "read", "write", "pool")] + numbers = [ + float(p) for p in phases if isinstance(p, (int, float)) and not isinstance(p, bool) + ] + seconds = max(numbers) if numbers else None + if seconds is None or not math.isfinite(seconds) or seconds <= 0: + return None + return min(math.ceil(seconds), MAX_TTL_SECONDS) + + +def request_status_of(error: BaseException) -> AiRequestStatus: + """How a provider call that raised ``error`` ended, as a commit reports it. + + An HTTP answer is the provider's (5xx, 408, 429) or the caller's (any other 4xx). + Only explicit connection establishment or pool acquisition failures prove the call was + not sent. Other failures may happen after sending, so they are conservatively reported + as ``PROVIDER_ERROR``, never released as ``NOT_SENT``. + """ + status = getattr(error, "status_code", None) + if isinstance(status, int) and not isinstance(status, bool): + if status >= 500 or status in (408, 429): + return "PROVIDER_ERROR" + if status >= 400: + return "CUSTOMER_ERROR" + if isinstance( + error, + (ConnectionRefusedError, httpx.ConnectError, httpx.ConnectTimeout, httpx.PoolTimeout), + ): + return "NOT_SENT" + return "PROVIDER_ERROR" diff --git a/src/kaitencloud/_ai_models.py b/src/kaitencloud/_ai_models.py new file mode 100644 index 0000000..1a88126 --- /dev/null +++ b/src/kaitencloud/_ai_models.py @@ -0,0 +1,76 @@ +# Copyright 2026 KAITEN INC +# SPDX-License-Identifier: Apache-2.0 + +"""The answer to an AI usage report, written by hand until the contract carries it. + +Every other model is generated from ``openapi/openapi.yaml``. These three describe the members +the AI credits contract adds to ``EntitlementUsage`` -- ``reserved`` and ``ai`` -- and are +isolated here so that the regenerated ``EntitlementUsage`` and ``AiUsageOutcome`` replace them +with no change elsewhere: delete this module and import from :mod:`kaitencloud.types`. +""" + +from __future__ import annotations + +from datetime import datetime +from typing import Literal + +from pydantic import Field + +from ._models import KaitenModel +from .types import EntitlementUsage + +__all__ = ["AiCredits", "AiEntitlementUsage", "AiUsageOutcome"] + + +class AiCredits(KaitenModel): + """Credits an AI usage report moved, as decimal strings with at most six decimals.""" + + reserved: str | None = None + """What a reservation set aside, or what a commit closed.""" + + computed: str | None = None + """What the conversion rule priced the call at, before its policy and guardrails.""" + + charged: str | None = None + """What the commit charged the pool.""" + + +class AiUsageOutcome(KaitenModel): + """What an AI usage report did, next to the usage it leaves the pool at.""" + + transaction_id: str = Field(alias="transactionId") + """The report's transaction id, echoed.""" + + phase: Literal["reserve", "commit"] | str + """The phase reported.""" + + status: Literal["RESERVED", "COMMITTED", "RELEASED"] | str + """The operation's status after this report.""" + + credits: AiCredits = Field(default_factory=AiCredits) + """The credits this report reserved, computed or charged.""" + + remaining: str | None = None + """Credits left: the limit, minus usage, minus open reservations. ``None`` when unlimited, + negative when overdrawn.""" + + expires_at: datetime | None = Field(default=None, alias="expiresAt") + """When the reservation lapses. Reservations only.""" + + authorized: bool | None = None + """Commits only: ``False`` when no rule priced the call, or the model is unknown or + archived.""" + + warnings: list[str] = Field(default_factory=list) + """``MODEL_DEPRECATED``, ``ESTIMATE_INCOMPLETE``, ``CONVERSION_INPUT_MISSING``, + ``RESERVATION_EXPIRED``, ``OVERDRAWN``, ``MODEL_NOT_AUTHORIZED``, ``UNKNOWN_MODEL``.""" + + +class AiEntitlementUsage(EntitlementUsage): + """An entitlement's usage after an AI usage report, with what the report did.""" + + reserved: str | None = None + """Credits held by the pool's open, unexpired reservations, as a decimal string.""" + + ai: AiUsageOutcome | None = None + """What the AI usage report did.""" diff --git a/src/kaitencloud/_async/_http.py b/src/kaitencloud/_async/_http.py index b96196c..d1b3c6e 100644 --- a/src/kaitencloud/_async/_http.py +++ b/src/kaitencloud/_async/_http.py @@ -6,7 +6,7 @@ from __future__ import annotations import logging -from collections.abc import AsyncIterator, Mapping +from collections.abc import AsyncIterator, Collection, Mapping from typing import Any, TypeVar import httpx @@ -98,6 +98,9 @@ async def request( body: Any = None, idempotent: bool | None = None, errors: Mapping[int, type[APIStatusError]] | None = None, + headers: Mapping[str, str] | None = None, + max_retries: int | None = None, + retry_statuses: Collection[int] | None = None, ) -> httpx.Response: """Send one operation and return its successful response. @@ -106,20 +109,29 @@ async def request( is idempotent, ``idempotent=True`` says so, or it never left the machine. A 401 or 403 is never retried -- the SDK cannot mint a better credential, and hammering a rejected one only burns the rate budget. + + ``headers`` are sent on every attempt, so a key in them is the same on each retry. + ``max_retries`` and ``retry_statuses`` narrow the policy for one call: the AI usage + phases retry a fixed number of times, on a fixed set of statuses. """ url = self.base_url + path.lstrip("/") retryable = method in IDEMPOTENT_METHODS if idempotent is None else idempotent + retries = self.max_retries if max_retries is None else max_retries attempt = 0 while True: request = self._build( - method, url, params=_query(params), body=body, headers=await self._headers() + method, + url, + params=_query(params), + body=body, + headers={**await self._headers(), **(headers or {})}, ) try: response = await self.http_client.send(request) except httpx.TransportError as error: - if attempt < self.max_retries and (retryable or isinstance(error, NEVER_SENT)): - await self._back_off(request, attempt, repr(error)) + if attempt < retries and (retryable or isinstance(error, NEVER_SENT)): + await self._back_off(request, attempt, repr(error), None, retries) attempt += 1 continue if isinstance(error, httpx.TimeoutException): @@ -130,9 +142,14 @@ async def request( return response status = response.status_code - if retryable and attempt < self.max_retries and (status in (408, 429) or status >= 500): + worth_retrying = ( + status in (408, 429) or status >= 500 + if retry_statuses is None + else status in retry_statuses + ) + if retryable and attempt < retries and worth_retrying: await self._back_off( - request, attempt, f"HTTP {status}", response.headers.get("retry-after") + request, attempt, f"HTTP {status}", response.headers.get("retry-after"), retries ) attempt += 1 continue @@ -167,9 +184,20 @@ async def send( body: Any = None, idempotent: bool | None = None, errors: Mapping[int, type[APIStatusError]] | None = None, + headers: Mapping[str, str] | None = None, + max_retries: int | None = None, + retry_statuses: Collection[int] | None = None, ) -> ModelT: response = await self.request( - method, path, params=params, body=body, idempotent=idempotent, errors=errors + method, + path, + params=params, + body=body, + idempotent=idempotent, + errors=errors, + headers=headers, + max_retries=max_retries, + retry_statuses=retry_statuses, ) return self.validate(model, self.decode(response), response) @@ -301,7 +329,12 @@ async def _headers(self) -> dict[str, str]: } async def _back_off( - self, request: httpx.Request, attempt: int, reason: str, retry_after: str | None = None + self, + request: httpx.Request, + attempt: int, + reason: str, + retry_after: str | None = None, + retries: int | None = None, ) -> None: delay = retry_delay(attempt, retry_after) log.info( @@ -310,7 +343,7 @@ async def _back_off( request.url.path, delay, attempt + 1, - self.max_retries, + self.max_retries if retries is None else retries, reason, ) await async_sleep(delay) diff --git a/src/kaitencloud/_async/ai.py b/src/kaitencloud/_async/ai.py new file mode 100644 index 0000000..57b50fe --- /dev/null +++ b/src/kaitencloud/_async/ai.py @@ -0,0 +1,238 @@ +# Copyright 2026 KAITEN INC +# SPDX-License-Identifier: Apache-2.0 + +"""Reserve, call, commit: the loop every AI wrapper runs, for one instance and one pool.""" + +from __future__ import annotations + +import logging +from collections.abc import Awaitable, Callable +from typing import TypeVar + +from .._ai import ( + AiRequest, + Estimator, + UsageOf, + ai_usage_body, + default_estimator, + new_transaction_id, + request_status_of, +) +from .._ai_models import AiEntitlementUsage +from .._exceptions import AiUsageUnsupportedError, KaitenError +from ..types import AiRequestStatus, AiRequestType, AiUsageParam +from .client import AsyncKaitenClient + +__all__ = ["AsyncMeter", "async_run"] + +T = TypeVar("T") + +log = logging.getLogger("kaitencloud.ai") + + +class AsyncMeter: + """Meters AI calls against one AI credit pool of one instance. + + Every call is one operation: :meth:`reserve` before it, :meth:`commit` after it, under one + transaction id. A reservation the pool refuses raises its typed error, and the call must + not be made. Everything after the call is best effort: a commit that still fails after its + retries is logged and dropped, because the call already happened -- the reservation then + expires on its own, and the call is in this process's logs, not in Kaiten. + + A deployment that does not know AI usage answers the first report with a 422. The meter + logs it once, loudly, and from then on lets every call through unreported: metering never + takes the product down. + """ + + def __init__( + self, + kaiten: AsyncKaitenClient, + *, + instance: str, + pool: str, + estimator: Estimator | None = None, + ) -> None: + self.kaiten = kaiten + self.instance = instance + self.pool = pool + self.estimator: Estimator = estimator or default_estimator + self.unsupported = False + + def __repr__(self) -> str: + return f"<{type(self).__name__} instance={self.instance!r} pool={self.pool!r}>" + + def estimate(self, request: AiRequest) -> AiUsageParam | None: + """The usage to reserve for ``request``, from the meter's estimator.""" + return self.estimator(request) + + async def reserve( + self, + *, + model: str, + request_type: AiRequestType = "COMPLETION", + usage: AiUsageParam | None = None, + transaction_id: str | None = None, + ttl_seconds: int | None = None, + ) -> str | None: + """Reserve for one call and return its transaction id; ``None`` when unmetered. + + Raises the reservation's typed error -- ``InsufficientCreditsError``, + ``UnknownModelError``, ``ModelNotAuthorizedError`` -- when the pool refuses it. Only + an unsupported deployment is swallowed, after it was logged once. + """ + if self.unsupported: + return None + transaction_id = transaction_id or new_transaction_id() + try: + await self.kaiten.instances.reserve_ai_usage( + self.instance, + self.pool, + model=model, + request_type=request_type, + transaction_id=transaction_id, + usage=usage, + ttl_seconds=ttl_seconds, + ) + except AiUsageUnsupportedError as error: + self._disable(error) + return None + return transaction_id + + async def commit( + self, + transaction_id: str | None, + *, + request_status: AiRequestStatus = "SUCCESS", + usage: AiUsageParam | None = None, + model: str | None = None, + request_type: AiRequestType | None = None, + ) -> AiEntitlementUsage | None: + """Commit one call; never raises, because the call it accounts for already happened. + + ``transaction_id`` is the reservation's. ``None`` -- a call that went unreserved -- + commits under a fresh id, with ``model`` and ``request_type`` naming what ran. + """ + if self.unsupported: + return None + if transaction_id is None: + transaction_id = new_transaction_id() + if usage is not None: + try: + ai_usage_body(usage) + except Exception: + log.exception( + "Kaiten AI usage: invalid usage for transaction %s on %s/%s; " + "attempting settlement without usage", + transaction_id, + self.instance, + self.pool, + ) + usage = None + try: + return await self.kaiten.instances.commit_ai_usage( + self.instance, + self.pool, + transaction_id=transaction_id, + request_status=request_status, + usage=usage, + model=model, + request_type=request_type, + ) + except AiUsageUnsupportedError as error: + self._disable(error) + except (KaitenError, ValueError): + log.exception( + "Kaiten AI usage: the commit of transaction %s on %s/%s failed; its " + "reservation will expire and the call goes unbilled", + transaction_id, + self.instance, + self.pool, + ) + return None + + async def release(self, transaction_id: str | None) -> None: + """Release a reservation whose call never reached the provider: nothing is charged.""" + if transaction_id is not None: + await self.commit(transaction_id, request_status="NOT_SENT") + + async def run( + self, + fn: Callable[[], Awaitable[T]], + *, + model: str, + request_type: AiRequestType = "COMPLETION", + estimate: AiUsageParam | None = None, + usage: UsageOf | None = None, + transaction_id: str | None = None, + ttl_seconds: int | None = None, + ) -> T: + """Reserve ``estimate``, call ``fn``, commit what it reports, and return its result. + + ``usage`` reads the consumption out of ``fn``'s result. When ``fn`` raises, the commit + says how it ended (:func:`~kaitencloud.ai.request_status_of`) and the error propagates. + """ + reserved = await self.reserve( + model=model, + request_type=request_type, + usage=estimate, + transaction_id=transaction_id, + ttl_seconds=ttl_seconds, + ) + if reserved is None: + return await fn() + try: + result = await fn() + except Exception as error: + await self.commit(reserved, request_status=request_status_of(error)) + raise + actual_usage = None + if usage is not None: + try: + actual_usage = usage(result) + except Exception: + log.exception( + "Kaiten AI usage: usage extraction for transaction %s on %s/%s failed; " + "attempting settlement without usage", + reserved, + self.instance, + self.pool, + ) + await self.commit(reserved, usage=actual_usage) + return result + + def _disable(self, error: AiUsageUnsupportedError) -> None: + if not self.unsupported: + log.error( + "Kaiten AI usage is NOT being reported for %s/%s: %s. Calls go through " + "unmetered until this client is recreated.", + self.instance, + self.pool, + error, + ) + self.unsupported = True + + +async def async_run( + kaiten: AsyncKaitenClient, + fn: Callable[[], Awaitable[T]], + *, + instance: str, + pool: str, + model: str, + request_type: AiRequestType = "COMPLETION", + estimate: AiUsageParam | None = None, + usage: UsageOf | None = None, + transaction_id: str | None = None, + ttl_seconds: int | None = None, +) -> T: + """One metered call: see :meth:`AsyncMeter.run`.""" + meter = AsyncMeter(kaiten, instance=instance, pool=pool) + return await meter.run( + fn, + model=model, + request_type=request_type, + estimate=estimate, + usage=usage, + transaction_id=transaction_id, + ttl_seconds=ttl_seconds, + ) diff --git a/src/kaitencloud/_async/resources/instances.py b/src/kaitencloud/_async/resources/instances.py index d7b6906..239571c 100644 --- a/src/kaitencloud/_async/resources/instances.py +++ b/src/kaitencloud/_async/resources/instances.py @@ -4,11 +4,20 @@ from __future__ import annotations import builtins +import uuid from collections.abc import Mapping from datetime import datetime from typing import Any -from ..._exceptions import ThresholdExceededError +from ..._ai import ( + AI_MAX_RETRIES, + AI_RETRY_STATUSES, + ai_usage_body, + check_transaction_id, + new_transaction_id, +) +from ..._ai_models import AiEntitlementUsage +from ..._exceptions import APIStatusError, ThresholdExceededError, make_ai_error from ..._utils import ( api_path, compact, @@ -18,6 +27,9 @@ optional_datetime, ) from ...types import ( + AiRequestStatus, + AiRequestType, + AiUsageParam, AuditTrail, EntitlementUsage, Instance, @@ -257,6 +269,138 @@ async def report_usage( errors={409: ThresholdExceededError}, ) + async def reserve_ai_usage( + self, + instance_slug: str, + entitlement_slug: str, + *, + model: str, + request_type: AiRequestType = "COMPLETION", + transaction_id: str | None = None, + usage: AiUsageParam | None = None, + ttl_seconds: int | None = None, + metadata: Mapping[str, Any] | None = None, + ) -> AiEntitlementUsage: + """Reserve the credits an AI call is expected to cost, before making it. + + Kaiten prices ``usage`` with the conversion rule for ``model`` and holds the result + against the AI credit pool ``entitlement_slug`` until the call is committed with + :meth:`commit_ai_usage`, or the reservation expires. Usage does not move: only a + commit charges. + + Args: + instance_slug: The instance making the call. + entitlement_slug: The AI credit pool it draws on. + model: The model, as a catalogue slug or a provider id such as ``"gpt-4o"``. + request_type: What the call produces: ``"COMPLETION"`` (the default), + ``"EMBEDDING"``, ``"IMAGE_GENERATION"``, ``"AUDIO"`` or ``"VIDEO"``. + transaction_id: Pairs this reservation with its commit. A fresh UUIDv7 when + omitted; read it back from the answer's ``ai.transaction_id``. + usage: The estimate to price. + ttl_seconds: How long the reservation holds; the server's default otherwise. + metadata: Free-form metadata, accepted and not stored. + + Raises: + InsufficientCreditsError: The estimate does not fit in what is left of the pool. + UnknownModelError: The model is not in the catalogue. + ModelNotAuthorizedError: The model is archived, or no rule prices it. + TransactionIdReusedError: The transaction id already served another call. + AiUsageUnsupportedError: The deployment or the entitlement cannot take AI usage. + + Retried up to three times on a network error and on a 500, 502, 503 or 504, under one + ``Idempotency-Key`` and one transaction id: the API answers a repeat with the original. + """ + value = compact( + { + "type": "ai_usage", + "phase": "reserve", + "transactionId": check_transaction_id(transaction_id or new_transaction_id()), + "model": model, + "requestType": request_type, + "usage": ai_usage_body(usage) if usage is not None else None, + "ttlSeconds": ttl_seconds, + } + ) + return await self._report_ai_usage(instance_slug, entitlement_slug, value, metadata) + + async def commit_ai_usage( + self, + instance_slug: str, + entitlement_slug: str, + *, + transaction_id: str, + request_status: AiRequestStatus = "SUCCESS", + usage: AiUsageParam | None = None, + model: str | None = None, + request_type: AiRequestType | None = None, + metadata: Mapping[str, Any] | None = None, + ) -> AiEntitlementUsage: + """Report what an AI call really consumed, and charge the AI credit pool for it. + + Closes the reservation ``transaction_id`` opened. A commit with no reservation behind + it is accepted too -- pass ``model`` and ``request_type`` then -- and a commit is never + refused for a commercial reason: an unknown model, a call no rule prices or a pool past + its limit are recorded, and answered with warnings. + + Args: + instance_slug: The instance that made the call. + entitlement_slug: The AI credit pool it drew on. + transaction_id: The reservation's transaction id. + request_status: ``"SUCCESS"`` (the default), ``"PROVIDER_ERROR"``, + ``"CUSTOMER_ERROR"``, or ``"NOT_SENT"`` when the provider was never reached: + the reservation is released and nothing is charged. + usage: What the call consumed, as the provider reported it. + model: The model that ran, when it is not the one reserved. + request_type: What the call produced, when it is not what was reserved. + metadata: Free-form metadata, accepted and not stored. + + Raises: + TransactionIdReusedError: The transaction was already committed with another body. + AiUsageUnsupportedError: The deployment or the entitlement cannot take AI usage. + + Retried like :meth:`reserve_ai_usage`; a repeat of a commit that landed is answered + with the original, and charges nothing more. + """ + value = compact( + { + "type": "ai_usage", + "phase": "commit", + "transactionId": check_transaction_id(transaction_id), + "requestStatus": request_status, + "model": model, + "requestType": request_type, + "usage": ai_usage_body(usage) if usage is not None else None, + } + ) + return await self._report_ai_usage(instance_slug, entitlement_slug, value, metadata) + + async def _report_ai_usage( + self, + instance_slug: str, + entitlement_slug: str, + value: dict[str, Any], + metadata: Mapping[str, Any] | None, + ) -> AiEntitlementUsage: + body = compact( + {"value": value, "metadata": dict(metadata) if metadata is not None else None} + ) + try: + return await self._api.send( + "POST", + api_path("instances", instance_slug, "entitlements", entitlement_slug, "usage"), + AiEntitlementUsage, + body=body, + idempotent=True, + headers={"Idempotency-Key": str(uuid.uuid4())}, + max_retries=AI_MAX_RETRIES, + retry_statuses=AI_RETRY_STATUSES, + ) + except APIStatusError as error: + mapped = make_ai_error(error) + if mapped is error: + raise + raise mapped from error + async def get_integration( self, instance_slug: str, integration_name: str ) -> InstanceIntegration: diff --git a/src/kaitencloud/_exceptions.py b/src/kaitencloud/_exceptions.py index 23c680e..c185976 100644 --- a/src/kaitencloud/_exceptions.py +++ b/src/kaitencloud/_exceptions.py @@ -18,18 +18,23 @@ "APIResponseValidationError", "APIStatusError", "APITimeoutError", + "AiUsageUnsupportedError", "AuthenticationError", "BadRequestError", "ConflictError", "CredentialError", + "InsufficientCreditsError", "InternalServerError", "KaitenError", + "ModelNotAuthorizedError", "NotFoundError", "PaginationError", "PermissionDeniedError", "RateLimitError", "ServiceUnavailableError", "ThresholdExceededError", + "TransactionIdReusedError", + "UnknownModelError", "UnprocessableEntityError", "WebhookPayloadError", "WebhookVerificationError", @@ -166,6 +171,37 @@ class UnprocessableEntityError(APIStatusError): """The API answered 422: the request is well-formed but its content is refused.""" +class InsufficientCreditsError(ThresholdExceededError): + """An AI usage reservation does not fit in what is left of the credit pool. + + Raised before the provider is called: nothing was reserved, and nothing is owed. + """ + + +class TransactionIdReusedError(ConflictError): + """An AI usage transaction id was already used for another call. + + The same id and phase arrived with another body, or a reservation was made on an id that is + already reserved or closed. A bug in the caller, never a transient failure: not retried. + """ + + +class ModelNotAuthorizedError(UnprocessableEntityError): + """The model may not be reserved against: it is archived, or no conversion rule prices it.""" + + +class UnknownModelError(UnprocessableEntityError): + """The model is in neither the catalogue's slugs nor its provider ids.""" + + +class AiUsageUnsupportedError(UnprocessableEntityError): + """The API cannot take an AI usage report on this entitlement. + + Either the deployment predates AI credits, or the entitlement is not an AI credit pool + aggregated by ``SUM``. The provider wrappers log it once and keep serving calls unreported. + """ + + class RateLimitError(APIStatusError): """The API answered 429.""" @@ -216,6 +252,44 @@ def make_status_error( return error_class(_message(response, problem), response=response, problem=problem) +# The 422 codes an AI usage report can answer with on a deployment that knows the variant. +# Any other 422 to such a report means the deployment does not. +_AI_INVALID_CODES = frozenset( + { + "TransactionIdRequired", + "InvalidTransactionId", + "InvalidAiUsage", + "AiUsageRequiresAppend", + "AiCreditPoolAppendOnly", + } +) + + +def make_ai_error(error: APIStatusError) -> APIStatusError: + """The typed error an AI usage report answered with ``error`` stands for. + + The status alone does not say it: a 409 is a full pool or a reused transaction id, and a + 422 is a refused model, a malformed body or a deployment without AI credits. + """ + reason = (error.code or "").rsplit(".", 1)[-1] + target: type[APIStatusError] | None = None + if error.status_code == 409: + target = { + "ThresholdExceeded": InsufficientCreditsError, + "TransactionIdReused": TransactionIdReusedError, + }.get(reason) + elif error.status_code == 422: + target = { + "UnknownModel": UnknownModelError, + "ModelNotAuthorized": ModelNotAuthorizedError, + }.get(reason) + if target is None and reason not in _AI_INVALID_CODES: + target = AiUsageUnsupportedError + if target is None: + return error + return target(str(error), response=error.response, problem=error.problem) + + def parse_problem(response: httpx.Response) -> Problem | None: """The RFC 9457 problem in ``response``, or ``None`` when the body is not one. diff --git a/src/kaitencloud/_sync/_http.py b/src/kaitencloud/_sync/_http.py index 7c44ffc..7a45c70 100644 --- a/src/kaitencloud/_sync/_http.py +++ b/src/kaitencloud/_sync/_http.py @@ -8,7 +8,7 @@ from __future__ import annotations import logging -from collections.abc import Iterator, Mapping +from collections.abc import Collection, Iterator, Mapping from typing import Any, TypeVar import httpx @@ -100,6 +100,9 @@ def request( body: Any = None, idempotent: bool | None = None, errors: Mapping[int, type[APIStatusError]] | None = None, + headers: Mapping[str, str] | None = None, + max_retries: int | None = None, + retry_statuses: Collection[int] | None = None, ) -> httpx.Response: """Send one operation and return its successful response. @@ -108,20 +111,29 @@ def request( is idempotent, ``idempotent=True`` says so, or it never left the machine. A 401 or 403 is never retried -- the SDK cannot mint a better credential, and hammering a rejected one only burns the rate budget. + + ``headers`` are sent on every attempt, so a key in them is the same on each retry. + ``max_retries`` and ``retry_statuses`` narrow the policy for one call: the AI usage + phases retry a fixed number of times, on a fixed set of statuses. """ url = self.base_url + path.lstrip("/") retryable = method in IDEMPOTENT_METHODS if idempotent is None else idempotent + retries = self.max_retries if max_retries is None else max_retries attempt = 0 while True: request = self._build( - method, url, params=_query(params), body=body, headers=self._headers() + method, + url, + params=_query(params), + body=body, + headers={**self._headers(), **(headers or {})}, ) try: response = self.http_client.send(request) except httpx.TransportError as error: - if attempt < self.max_retries and (retryable or isinstance(error, NEVER_SENT)): - self._back_off(request, attempt, repr(error)) + if attempt < retries and (retryable or isinstance(error, NEVER_SENT)): + self._back_off(request, attempt, repr(error), None, retries) attempt += 1 continue if isinstance(error, httpx.TimeoutException): @@ -132,9 +144,14 @@ def request( return response status = response.status_code - if retryable and attempt < self.max_retries and (status in (408, 429) or status >= 500): + worth_retrying = ( + status in (408, 429) or status >= 500 + if retry_statuses is None + else status in retry_statuses + ) + if retryable and attempt < retries and worth_retrying: self._back_off( - request, attempt, f"HTTP {status}", response.headers.get("retry-after") + request, attempt, f"HTTP {status}", response.headers.get("retry-after"), retries ) attempt += 1 continue @@ -169,9 +186,20 @@ def send( body: Any = None, idempotent: bool | None = None, errors: Mapping[int, type[APIStatusError]] | None = None, + headers: Mapping[str, str] | None = None, + max_retries: int | None = None, + retry_statuses: Collection[int] | None = None, ) -> ModelT: response = self.request( - method, path, params=params, body=body, idempotent=idempotent, errors=errors + method, + path, + params=params, + body=body, + idempotent=idempotent, + errors=errors, + headers=headers, + max_retries=max_retries, + retry_statuses=retry_statuses, ) return self.validate(model, self.decode(response), response) @@ -303,7 +331,12 @@ def _headers(self) -> dict[str, str]: } def _back_off( - self, request: httpx.Request, attempt: int, reason: str, retry_after: str | None = None + self, + request: httpx.Request, + attempt: int, + reason: str, + retry_after: str | None = None, + retries: int | None = None, ) -> None: delay = retry_delay(attempt, retry_after) log.info( @@ -312,7 +345,7 @@ def _back_off( request.url.path, delay, attempt + 1, - self.max_retries, + self.max_retries if retries is None else retries, reason, ) sleep(delay) diff --git a/src/kaitencloud/_sync/ai.py b/src/kaitencloud/_sync/ai.py new file mode 100644 index 0000000..b8dd6d0 --- /dev/null +++ b/src/kaitencloud/_sync/ai.py @@ -0,0 +1,240 @@ +# Copyright 2026 KAITEN INC +# SPDX-License-Identifier: Apache-2.0 + +# Generated by scripts/unasync.py from src/kaitencloud/_async/ai.py. Do not edit by hand: +# change the async source, then run `task generate`. +"""Reserve, call, commit: the loop every AI wrapper runs, for one instance and one pool.""" + +from __future__ import annotations + +import logging +from collections.abc import Callable +from typing import TypeVar + +from .._ai import ( + AiRequest, + Estimator, + UsageOf, + ai_usage_body, + default_estimator, + new_transaction_id, + request_status_of, +) +from .._ai_models import AiEntitlementUsage +from .._exceptions import AiUsageUnsupportedError, KaitenError +from ..types import AiRequestStatus, AiRequestType, AiUsageParam +from .client import KaitenClient + +__all__ = ["Meter", "run"] + +T = TypeVar("T") + +log = logging.getLogger("kaitencloud.ai") + + +class Meter: + """Meters AI calls against one AI credit pool of one instance. + + Every call is one operation: :meth:`reserve` before it, :meth:`commit` after it, under one + transaction id. A reservation the pool refuses raises its typed error, and the call must + not be made. Everything after the call is best effort: a commit that still fails after its + retries is logged and dropped, because the call already happened -- the reservation then + expires on its own, and the call is in this process's logs, not in Kaiten. + + A deployment that does not know AI usage answers the first report with a 422. The meter + logs it once, loudly, and from then on lets every call through unreported: metering never + takes the product down. + """ + + def __init__( + self, + kaiten: KaitenClient, + *, + instance: str, + pool: str, + estimator: Estimator | None = None, + ) -> None: + self.kaiten = kaiten + self.instance = instance + self.pool = pool + self.estimator: Estimator = estimator or default_estimator + self.unsupported = False + + def __repr__(self) -> str: + return f"<{type(self).__name__} instance={self.instance!r} pool={self.pool!r}>" + + def estimate(self, request: AiRequest) -> AiUsageParam | None: + """The usage to reserve for ``request``, from the meter's estimator.""" + return self.estimator(request) + + def reserve( + self, + *, + model: str, + request_type: AiRequestType = "COMPLETION", + usage: AiUsageParam | None = None, + transaction_id: str | None = None, + ttl_seconds: int | None = None, + ) -> str | None: + """Reserve for one call and return its transaction id; ``None`` when unmetered. + + Raises the reservation's typed error -- ``InsufficientCreditsError``, + ``UnknownModelError``, ``ModelNotAuthorizedError`` -- when the pool refuses it. Only + an unsupported deployment is swallowed, after it was logged once. + """ + if self.unsupported: + return None + transaction_id = transaction_id or new_transaction_id() + try: + self.kaiten.instances.reserve_ai_usage( + self.instance, + self.pool, + model=model, + request_type=request_type, + transaction_id=transaction_id, + usage=usage, + ttl_seconds=ttl_seconds, + ) + except AiUsageUnsupportedError as error: + self._disable(error) + return None + return transaction_id + + def commit( + self, + transaction_id: str | None, + *, + request_status: AiRequestStatus = "SUCCESS", + usage: AiUsageParam | None = None, + model: str | None = None, + request_type: AiRequestType | None = None, + ) -> AiEntitlementUsage | None: + """Commit one call; never raises, because the call it accounts for already happened. + + ``transaction_id`` is the reservation's. ``None`` -- a call that went unreserved -- + commits under a fresh id, with ``model`` and ``request_type`` naming what ran. + """ + if self.unsupported: + return None + if transaction_id is None: + transaction_id = new_transaction_id() + if usage is not None: + try: + ai_usage_body(usage) + except Exception: + log.exception( + "Kaiten AI usage: invalid usage for transaction %s on %s/%s; " + "attempting settlement without usage", + transaction_id, + self.instance, + self.pool, + ) + usage = None + try: + return self.kaiten.instances.commit_ai_usage( + self.instance, + self.pool, + transaction_id=transaction_id, + request_status=request_status, + usage=usage, + model=model, + request_type=request_type, + ) + except AiUsageUnsupportedError as error: + self._disable(error) + except (KaitenError, ValueError): + log.exception( + "Kaiten AI usage: the commit of transaction %s on %s/%s failed; its " + "reservation will expire and the call goes unbilled", + transaction_id, + self.instance, + self.pool, + ) + return None + + def release(self, transaction_id: str | None) -> None: + """Release a reservation whose call never reached the provider: nothing is charged.""" + if transaction_id is not None: + self.commit(transaction_id, request_status="NOT_SENT") + + def run( + self, + fn: Callable[[], T], + *, + model: str, + request_type: AiRequestType = "COMPLETION", + estimate: AiUsageParam | None = None, + usage: UsageOf | None = None, + transaction_id: str | None = None, + ttl_seconds: int | None = None, + ) -> T: + """Reserve ``estimate``, call ``fn``, commit what it reports, and return its result. + + ``usage`` reads the consumption out of ``fn``'s result. When ``fn`` raises, the commit + says how it ended (:func:`~kaitencloud.ai.request_status_of`) and the error propagates. + """ + reserved = self.reserve( + model=model, + request_type=request_type, + usage=estimate, + transaction_id=transaction_id, + ttl_seconds=ttl_seconds, + ) + if reserved is None: + return fn() + try: + result = fn() + except Exception as error: + self.commit(reserved, request_status=request_status_of(error)) + raise + actual_usage = None + if usage is not None: + try: + actual_usage = usage(result) + except Exception: + log.exception( + "Kaiten AI usage: usage extraction for transaction %s on %s/%s failed; " + "attempting settlement without usage", + reserved, + self.instance, + self.pool, + ) + self.commit(reserved, usage=actual_usage) + return result + + def _disable(self, error: AiUsageUnsupportedError) -> None: + if not self.unsupported: + log.error( + "Kaiten AI usage is NOT being reported for %s/%s: %s. Calls go through " + "unmetered until this client is recreated.", + self.instance, + self.pool, + error, + ) + self.unsupported = True + + +def run( + kaiten: KaitenClient, + fn: Callable[[], T], + *, + instance: str, + pool: str, + model: str, + request_type: AiRequestType = "COMPLETION", + estimate: AiUsageParam | None = None, + usage: UsageOf | None = None, + transaction_id: str | None = None, + ttl_seconds: int | None = None, +) -> T: + """One metered call: see :meth:`Meter.run`.""" + meter = Meter(kaiten, instance=instance, pool=pool) + return meter.run( + fn, + model=model, + request_type=request_type, + estimate=estimate, + usage=usage, + transaction_id=transaction_id, + ttl_seconds=ttl_seconds, + ) diff --git a/src/kaitencloud/_sync/resources/instances.py b/src/kaitencloud/_sync/resources/instances.py index ecae47d..7fa21a2 100644 --- a/src/kaitencloud/_sync/resources/instances.py +++ b/src/kaitencloud/_sync/resources/instances.py @@ -6,11 +6,20 @@ from __future__ import annotations import builtins +import uuid from collections.abc import Mapping from datetime import datetime from typing import Any -from ..._exceptions import ThresholdExceededError +from ..._ai import ( + AI_MAX_RETRIES, + AI_RETRY_STATUSES, + ai_usage_body, + check_transaction_id, + new_transaction_id, +) +from ..._ai_models import AiEntitlementUsage +from ..._exceptions import APIStatusError, ThresholdExceededError, make_ai_error from ..._utils import ( api_path, compact, @@ -20,6 +29,9 @@ optional_datetime, ) from ...types import ( + AiRequestStatus, + AiRequestType, + AiUsageParam, AuditTrail, EntitlementUsage, Instance, @@ -259,6 +271,138 @@ def report_usage( errors={409: ThresholdExceededError}, ) + def reserve_ai_usage( + self, + instance_slug: str, + entitlement_slug: str, + *, + model: str, + request_type: AiRequestType = "COMPLETION", + transaction_id: str | None = None, + usage: AiUsageParam | None = None, + ttl_seconds: int | None = None, + metadata: Mapping[str, Any] | None = None, + ) -> AiEntitlementUsage: + """Reserve the credits an AI call is expected to cost, before making it. + + Kaiten prices ``usage`` with the conversion rule for ``model`` and holds the result + against the AI credit pool ``entitlement_slug`` until the call is committed with + :meth:`commit_ai_usage`, or the reservation expires. Usage does not move: only a + commit charges. + + Args: + instance_slug: The instance making the call. + entitlement_slug: The AI credit pool it draws on. + model: The model, as a catalogue slug or a provider id such as ``"gpt-4o"``. + request_type: What the call produces: ``"COMPLETION"`` (the default), + ``"EMBEDDING"``, ``"IMAGE_GENERATION"``, ``"AUDIO"`` or ``"VIDEO"``. + transaction_id: Pairs this reservation with its commit. A fresh UUIDv7 when + omitted; read it back from the answer's ``ai.transaction_id``. + usage: The estimate to price. + ttl_seconds: How long the reservation holds; the server's default otherwise. + metadata: Free-form metadata, accepted and not stored. + + Raises: + InsufficientCreditsError: The estimate does not fit in what is left of the pool. + UnknownModelError: The model is not in the catalogue. + ModelNotAuthorizedError: The model is archived, or no rule prices it. + TransactionIdReusedError: The transaction id already served another call. + AiUsageUnsupportedError: The deployment or the entitlement cannot take AI usage. + + Retried up to three times on a network error and on a 500, 502, 503 or 504, under one + ``Idempotency-Key`` and one transaction id: the API answers a repeat with the original. + """ + value = compact( + { + "type": "ai_usage", + "phase": "reserve", + "transactionId": check_transaction_id(transaction_id or new_transaction_id()), + "model": model, + "requestType": request_type, + "usage": ai_usage_body(usage) if usage is not None else None, + "ttlSeconds": ttl_seconds, + } + ) + return self._report_ai_usage(instance_slug, entitlement_slug, value, metadata) + + def commit_ai_usage( + self, + instance_slug: str, + entitlement_slug: str, + *, + transaction_id: str, + request_status: AiRequestStatus = "SUCCESS", + usage: AiUsageParam | None = None, + model: str | None = None, + request_type: AiRequestType | None = None, + metadata: Mapping[str, Any] | None = None, + ) -> AiEntitlementUsage: + """Report what an AI call really consumed, and charge the AI credit pool for it. + + Closes the reservation ``transaction_id`` opened. A commit with no reservation behind + it is accepted too -- pass ``model`` and ``request_type`` then -- and a commit is never + refused for a commercial reason: an unknown model, a call no rule prices or a pool past + its limit are recorded, and answered with warnings. + + Args: + instance_slug: The instance that made the call. + entitlement_slug: The AI credit pool it drew on. + transaction_id: The reservation's transaction id. + request_status: ``"SUCCESS"`` (the default), ``"PROVIDER_ERROR"``, + ``"CUSTOMER_ERROR"``, or ``"NOT_SENT"`` when the provider was never reached: + the reservation is released and nothing is charged. + usage: What the call consumed, as the provider reported it. + model: The model that ran, when it is not the one reserved. + request_type: What the call produced, when it is not what was reserved. + metadata: Free-form metadata, accepted and not stored. + + Raises: + TransactionIdReusedError: The transaction was already committed with another body. + AiUsageUnsupportedError: The deployment or the entitlement cannot take AI usage. + + Retried like :meth:`reserve_ai_usage`; a repeat of a commit that landed is answered + with the original, and charges nothing more. + """ + value = compact( + { + "type": "ai_usage", + "phase": "commit", + "transactionId": check_transaction_id(transaction_id), + "requestStatus": request_status, + "model": model, + "requestType": request_type, + "usage": ai_usage_body(usage) if usage is not None else None, + } + ) + return self._report_ai_usage(instance_slug, entitlement_slug, value, metadata) + + def _report_ai_usage( + self, + instance_slug: str, + entitlement_slug: str, + value: dict[str, Any], + metadata: Mapping[str, Any] | None, + ) -> AiEntitlementUsage: + body = compact( + {"value": value, "metadata": dict(metadata) if metadata is not None else None} + ) + try: + return self._api.send( + "POST", + api_path("instances", instance_slug, "entitlements", entitlement_slug, "usage"), + AiEntitlementUsage, + body=body, + idempotent=True, + headers={"Idempotency-Key": str(uuid.uuid4())}, + max_retries=AI_MAX_RETRIES, + retry_statuses=AI_RETRY_STATUSES, + ) + except APIStatusError as error: + mapped = make_ai_error(error) + if mapped is error: + raise + raise mapped from error + def get_integration(self, instance_slug: str, integration_name: str) -> InstanceIntegration: """Return the instance's link to the ``integration_name`` adapter.""" return self._api.get( diff --git a/src/kaitencloud/ai.py b/src/kaitencloud/ai.py new file mode 100644 index 0000000..5a98e5c --- /dev/null +++ b/src/kaitencloud/ai.py @@ -0,0 +1,278 @@ +# Copyright 2026 KAITEN INC +# SPDX-License-Identifier: Apache-2.0 + +"""AI credits: meter every model call against an instance's AI credit pool. + +Kaiten prices AI usage on the server, with the conversion rules of the instance's license; the +SDK only says what was used. Each call is one operation in two phases, paired by a transaction +id: a **reservation** of the call's estimate before it -- refused when the pool cannot cover +it -- and a **commit** of its real usage after it. + +The provider wrappers do all of this with one changed line:: + + from kaitencloud.openai import OpenAI # instead of: from openai import OpenAI + + openai = OpenAI(kaiten=kaiten, instance="acme-prod", pool="chat-credits") + +Anything else goes through :func:`run`, which reserves, calls, and commits:: + + from kaitencloud import ai + + reply = ai.run( + kaiten, + lambda: llm.generate(prompt), + instance="acme-prod", + pool="chat-credits", + model="gpt-4o", + estimate={"input_tokens": 1200, "output_tokens": 800}, + usage=lambda reply: ai.from_openai(reply.usage), + ) + +or through the phases themselves, :func:`reserve` and :func:`commit`. With an +:class:`~kaitencloud.AsyncKaitenClient`, the same functions return awaitables and ``fn`` is a +coroutine function. +""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable, Coroutine +from typing import Any, TypeVar, overload + +from ._ai import ( + AiRequest, + Estimator, + UsageOf, + add_usage, + default_estimator, + from_anthropic, + from_openai, + new_transaction_id, + request_status_of, +) +from ._ai_models import AiCredits, AiEntitlementUsage, AiUsageOutcome +from ._async.ai import AsyncMeter, async_run +from ._async.client import AsyncKaitenClient +from ._exceptions import ( + AiUsageUnsupportedError, + InsufficientCreditsError, + ModelNotAuthorizedError, + TransactionIdReusedError, + UnknownModelError, +) +from ._sync.ai import Meter +from ._sync.ai import run as sync_run +from ._sync.client import KaitenClient +from .types import AiRequestStatus, AiRequestType, AiUnitType, AiUsageParam + +__all__ = [ + "AiCredits", + "AiEntitlementUsage", + "AiRequest", + "AiRequestStatus", + "AiRequestType", + "AiUnitType", + "AiUsageOutcome", + "AiUsageParam", + "AiUsageUnsupportedError", + "AsyncMeter", + "Estimator", + "InsufficientCreditsError", + "Meter", + "ModelNotAuthorizedError", + "TransactionIdReusedError", + "UnknownModelError", + "UsageOf", + "add_usage", + "commit", + "default_estimator", + "from_anthropic", + "from_openai", + "new_transaction_id", + "request_status_of", + "reserve", + "run", +] + +T = TypeVar("T") + + +@overload +def reserve( + kaiten: KaitenClient, + *, + instance: str, + pool: str, + model: str, + request_type: AiRequestType = ..., + usage: AiUsageParam | None = ..., + transaction_id: str | None = ..., + ttl_seconds: int | None = ..., +) -> AiEntitlementUsage: ... +@overload +def reserve( + kaiten: AsyncKaitenClient, + *, + instance: str, + pool: str, + model: str, + request_type: AiRequestType = ..., + usage: AiUsageParam | None = ..., + transaction_id: str | None = ..., + ttl_seconds: int | None = ..., +) -> Coroutine[Any, Any, AiEntitlementUsage]: ... +def reserve( + kaiten: KaitenClient | AsyncKaitenClient, + *, + instance: str, + pool: str, + model: str, + request_type: AiRequestType = "COMPLETION", + usage: AiUsageParam | None = None, + transaction_id: str | None = None, + ttl_seconds: int | None = None, +) -> AiEntitlementUsage | Coroutine[Any, Any, AiEntitlementUsage]: + """Reserve the credits a call is expected to cost; see ``instances.reserve_ai_usage``. + + The transaction id to commit under is the answer's ``ai.transaction_id`` -- a fresh UUIDv7 + unless ``transaction_id`` is given. + """ + return kaiten.instances.reserve_ai_usage( + instance, + pool, + model=model, + request_type=request_type, + usage=usage, + transaction_id=transaction_id, + ttl_seconds=ttl_seconds, + ) + + +@overload +def commit( + kaiten: KaitenClient, + *, + instance: str, + pool: str, + transaction_id: str, + request_status: AiRequestStatus = ..., + usage: AiUsageParam | None = ..., + model: str | None = ..., + request_type: AiRequestType | None = ..., +) -> AiEntitlementUsage: ... +@overload +def commit( + kaiten: AsyncKaitenClient, + *, + instance: str, + pool: str, + transaction_id: str, + request_status: AiRequestStatus = ..., + usage: AiUsageParam | None = ..., + model: str | None = ..., + request_type: AiRequestType | None = ..., +) -> Coroutine[Any, Any, AiEntitlementUsage]: ... +def commit( + kaiten: KaitenClient | AsyncKaitenClient, + *, + instance: str, + pool: str, + transaction_id: str, + request_status: AiRequestStatus = "SUCCESS", + usage: AiUsageParam | None = None, + model: str | None = None, + request_type: AiRequestType | None = None, +) -> AiEntitlementUsage | Coroutine[Any, Any, AiEntitlementUsage]: + """Charge what a call really consumed; see ``instances.commit_ai_usage``. + + ``request_status="NOT_SENT"`` releases a reservation whose call never reached the provider. + """ + return kaiten.instances.commit_ai_usage( + instance, + pool, + transaction_id=transaction_id, + request_status=request_status, + usage=usage, + model=model, + request_type=request_type, + ) + + +@overload +def run( + kaiten: KaitenClient, + fn: Callable[[], T], + *, + instance: str, + pool: str, + model: str, + request_type: AiRequestType = ..., + estimate: AiUsageParam | None = ..., + usage: UsageOf | None = ..., + transaction_id: str | None = ..., + ttl_seconds: int | None = ..., +) -> T: ... +@overload +def run( + kaiten: AsyncKaitenClient, + fn: Callable[[], Awaitable[T]], + *, + instance: str, + pool: str, + model: str, + request_type: AiRequestType = ..., + estimate: AiUsageParam | None = ..., + usage: UsageOf | None = ..., + transaction_id: str | None = ..., + ttl_seconds: int | None = ..., +) -> Coroutine[Any, Any, T]: ... +def run( + kaiten: KaitenClient | AsyncKaitenClient, + fn: Callable[[], Any], + *, + instance: str, + pool: str, + model: str, + request_type: AiRequestType = "COMPLETION", + estimate: AiUsageParam | None = None, + usage: UsageOf | None = None, + transaction_id: str | None = None, + ttl_seconds: int | None = None, +) -> Any: + """Reserve ``estimate``, call ``fn``, commit what it used, and return what ``fn`` returned. + + Args: + kaiten: The client to report through. With an async client, ``fn`` is a coroutine + function and ``run`` returns an awaitable. + fn: The provider call, taking no argument. + instance: The instance making the call. + pool: The AI credit pool it draws on. + model: The model, as a catalogue slug or a provider id. + request_type: What the call produces; ``"COMPLETION"`` by default. + estimate: The usage to reserve. + usage: Reads the real usage out of ``fn``'s result, e.g. with :func:`from_openai`. + transaction_id: The operation's id; a fresh UUIDv7 by default. + ttl_seconds: How long the reservation holds; the server's default otherwise. + + Raises: + InsufficientCreditsError: The pool cannot cover the estimate. ``fn`` was not called. + UnknownModelError: The model is not in the catalogue. ``fn`` was not called. + ModelNotAuthorizedError: No rule prices the model. ``fn`` was not called. + + When ``fn`` raises, the commit records how the call ended -- ``PROVIDER_ERROR``, + ``CUSTOMER_ERROR``, or ``NOT_SENT`` when it never reached the provider -- and the error + propagates. A commit that fails is logged, never raised: the call already happened. A + deployment without AI credits is logged once and ``fn`` runs unmetered. + """ + options: dict[str, Any] = { + "instance": instance, + "pool": pool, + "model": model, + "request_type": request_type, + "estimate": estimate, + "usage": usage, + "transaction_id": transaction_id, + "ttl_seconds": ttl_seconds, + } + if isinstance(kaiten, AsyncKaitenClient): + return async_run(kaiten, fn, **options) + return sync_run(kaiten, fn, **options) diff --git a/src/kaitencloud/types/__init__.py b/src/kaitencloud/types/__init__.py index 86d1c02..bbc5762 100644 --- a/src/kaitencloud/types/__init__.py +++ b/src/kaitencloud/types/__init__.py @@ -94,6 +94,10 @@ Variant, ) from ._params import ( + AiRequestStatus, + AiRequestType, + AiUnitType, + AiUsageParam, BasicTargetingParam, BasicVariantParam, DefaultVariantParam, @@ -113,6 +117,10 @@ __all__ = [ "AggregationMethod", + "AiRequestStatus", + "AiRequestType", + "AiUnitType", + "AiUsageParam", "AuditTrail", "BasicTargeting", "BasicTargetingParam", diff --git a/src/kaitencloud/types/_params.py b/src/kaitencloud/types/_params.py index e083959..c4e6162 100644 --- a/src/kaitencloud/types/_params.py +++ b/src/kaitencloud/types/_params.py @@ -15,11 +15,16 @@ from __future__ import annotations +from decimal import Decimal from typing import Any, Literal, TypeAlias from typing_extensions import Required, TypedDict __all__ = [ + "AiRequestStatus", + "AiRequestType", + "AiUnitType", + "AiUsageParam", "BasicTargetingParam", "BasicVariantParam", "DefaultVariantParam", @@ -124,3 +129,43 @@ class RolloutDateTargetingParam(TypedDict): TargetingParam: TypeAlias = ( BasicTargetingParam | RolloutPercentageTargetingParam | RolloutDateTargetingParam ) + + +AiRequestType: TypeAlias = Literal["COMPLETION", "EMBEDDING", "IMAGE_GENERATION", "AUDIO", "VIDEO"] +"""What an AI call produced, which picks the conversion rule that prices it.""" + +AiRequestStatus: TypeAlias = Literal["SUCCESS", "PROVIDER_ERROR", "CUSTOMER_ERROR", "NOT_SENT"] +"""How an AI call ended. ``NOT_SENT``: the provider was never reached, and nothing is charged.""" + +AiUnitType: TypeAlias = Literal["image", "second", "frame"] +"""What ``units`` counts in an :class:`AiUsageParam`.""" + + +class AiUsageParam(TypedDict, total=False): + """What an AI call consumed -- or, on a reservation, what it is expected to. + + The token counts are disjoint: ``input_tokens`` is the input neither read from nor written + to a cache, so the call's whole input is the sum of the three input fields. The normalisers + in :mod:`kaitencloud.ai` build one from a provider's own usage object. + """ + + input_tokens: int + """Input tokens neither read from nor written to a cache.""" + + cache_read_input_tokens: int + """Input tokens served from the provider's cache.""" + + cache_write_input_tokens: int + """Input tokens written to the provider's cache.""" + + output_tokens: int + """Output tokens, reasoning included.""" + + units: str | int | Decimal + """Generated units -- images, seconds, frames -- counted by ``unit_type``.""" + + unit_type: AiUnitType + """What ``units`` counts.""" + + duration_ms: int + """The billable duration, in milliseconds.""" diff --git a/tests/ai_fakes.py b/tests/ai_fakes.py new file mode 100644 index 0000000..7a15e2e --- /dev/null +++ b/tests/ai_fakes.py @@ -0,0 +1,109 @@ +# Copyright 2026 KAITEN INC +# SPDX-License-Identifier: Apache-2.0 + +"""A fake Kaiten answering AI usage reports, for the metering and wrapper tests. + +It answers each reservation and commit the way the API does -- 200, with the outcome echoing +the transaction id -- unless a test scripted a refusal for that phase. It never sees a prompt +or a completion, and neither do the tests: every usage in them is synthetic. +""" + +from __future__ import annotations + +import json +from collections.abc import Callable +from dataclasses import dataclass, field +from typing import Any + +import httpx + +USAGE = { + "entitlementId": "e1", + "entitlementSlug": "chat-credits", + "licenseId": "l1", + "licenseSlug": "pro-v3", + "value": {"type": "number", "value": 90}, + "limit": {"type": "number", "value": 100}, +} + +Answer = Callable[[httpx.Request], httpx.Response] + + +def problem(status: int, code: str | None, detail: str = "refused") -> Answer: + payload: dict[str, Any] = {"title": "Refused", "status": status, "detail": detail} + if code is not None: + payload["code"] = f"ReportEntitlementUsageMetric.{code}" + return lambda request: httpx.Response(status, json=payload) + + +def status(code: int) -> Answer: + return lambda request: httpx.Response(code, json={"title": "Unavailable", "detail": "down"}) + + +def network_error(request: httpx.Request) -> httpx.Response: + raise httpx.ReadError("connection reset", request=request) + + +@dataclass +class Report: + """One AI usage report the fake received.""" + + value: dict[str, Any] + body: dict[str, Any] + request: httpx.Request + + @property + def phase(self) -> str: + return str(self.value["phase"]) + + @property + def transaction_id(self) -> str: + return str(self.value["transactionId"]) + + @property + def key(self) -> str | None: + return self.request.headers.get("idempotency-key") + + +@dataclass +class FakeKaiten: + """Every AI report received, in order; ``script`` queues answers per phase.""" + + reports: list[Report] = field(default_factory=list) + script: dict[str, list[Answer]] = field(default_factory=dict) + + def __call__(self, request: httpx.Request) -> httpx.Response: + body = json.loads(request.content) + value = body["value"] + assert value["type"] == "ai_usage", value + self.reports.append(Report(value, body, request)) + queued = self.script.get(value["phase"]) + if queued: + return queued.pop(0)(request) + outcome = { + "transactionId": value["transactionId"], + "phase": value["phase"], + "status": "RESERVED" if value["phase"] == "reserve" else "COMMITTED", + "credits": {"reserved": "6.6"} + if value["phase"] == "reserve" + else {"reserved": "6.6", "computed": "1.2", "charged": "1.2"}, + "remaining": "3.4", + "warnings": [], + } + return httpx.Response(200, json={**USAGE, "reserved": "6.6", "ai": outcome}) + + def refuse(self, phase: str, *answers: Answer) -> None: + self.script.setdefault(phase, []).extend(answers) + + @property + def phases(self) -> list[tuple[str, str]]: + """(phase, transaction id) of every report: each retry appears once more.""" + return [(report.phase, report.transaction_id) for report in self.reports] + + @property + def commits(self) -> list[dict[str, Any]]: + return [report.value for report in self.reports if report.phase == "commit"] + + @property + def reserves(self) -> list[dict[str, Any]]: + return [report.value for report in self.reports if report.phase == "reserve"] diff --git a/tests/test_ai.py b/tests/test_ai.py new file mode 100644 index 0000000..b219ae0 --- /dev/null +++ b/tests/test_ai.py @@ -0,0 +1,661 @@ +# Copyright 2026 KAITEN INC +# SPDX-License-Identifier: Apache-2.0 + +"""AI credits: the two phases on the wire, their errors, their retries, and ``ai.run``.""" + +from __future__ import annotations + +import asyncio +import logging +import uuid +from collections.abc import Callable +from typing import Any + +import httpx +import pytest +from ai_fakes import FakeKaiten, network_error, problem, status + +from kaitencloud import ( + AiUsageUnsupportedError, + APIStatusError, + AsyncKaitenClient, + ConflictError, + InsufficientCreditsError, + KaitenClient, + ModelNotAuthorizedError, + ServiceUnavailableError, + ThresholdExceededError, + TransactionIdReusedError, + UnknownModelError, + UnprocessableEntityError, + ai, +) + +INSTANCE, POOL = "acme-prod", "chat-credits" + + +@pytest.fixture +def kaiten(make_client: Callable[..., Any]) -> tuple[KaitenClient, FakeKaiten]: + fake = FakeKaiten() + client, _ = make_client(fake) + return client, fake + + +# -- the phases on the wire --------------------------------------------------------------- + + +def test_a_reservation_sends_the_ai_variant_and_a_fresh_uuid7( + kaiten: tuple[KaitenClient, FakeKaiten], +) -> None: + client, fake = kaiten + answer = client.instances.reserve_ai_usage( + INSTANCE, + POOL, + model="gpt-4o", + usage={"input_tokens": 1000, "output_tokens": 4000}, + ttl_seconds=600, + ) + + report = fake.reports[0] + assert report.request.url.path == f"/api/instances/{INSTANCE}/entitlements/{POOL}/usage" + assert report.body == { + "value": { + "type": "ai_usage", + "phase": "reserve", + "transactionId": report.transaction_id, + "model": "gpt-4o", + "requestType": "COMPLETION", + "usage": {"inputTokens": 1000, "outputTokens": 4000}, + "ttlSeconds": 600, + } + } + assert uuid.UUID(report.transaction_id).version == 7 + assert report.key is not None + assert uuid.UUID(report.key).version == 4 + assert answer.ai is not None + assert answer.ai.transaction_id == report.transaction_id + assert answer.ai.status == "RESERVED" + assert answer.ai.credits.reserved == "6.6" + assert answer.reserved == "6.6" + assert answer.value.type == "number" + + +def test_a_commit_sends_only_what_the_reservation_does_not_know( + kaiten: tuple[KaitenClient, FakeKaiten], +) -> None: + client, fake = kaiten + answer = client.instances.commit_ai_usage( + INSTANCE, + POOL, + transaction_id="01JB2K7Z9Q", + usage={"input_tokens": 1000, "cache_read_input_tokens": 200, "output_tokens": 400}, + ) + + assert fake.reports[0].body == { + "value": { + "type": "ai_usage", + "phase": "commit", + "transactionId": "01JB2K7Z9Q", + "requestStatus": "SUCCESS", + "usage": {"inputTokens": 1000, "cacheReadInputTokens": 200, "outputTokens": 400}, + } + } + assert answer.ai is not None + assert answer.ai.credits.charged == "1.2" + + +def test_a_commit_without_a_reservation_names_what_ran( + kaiten: tuple[KaitenClient, FakeKaiten], +) -> None: + client, fake = kaiten + client.instances.commit_ai_usage( + INSTANCE, + POOL, + transaction_id="run-1:0", + model="claude-sonnet-4-5", + request_type="COMPLETION", + usage={"units": "2.5", "unit_type": "second", "duration_ms": 2500}, + metadata={"feature": "summaries"}, + ) + assert fake.reports[0].body == { + "value": { + "type": "ai_usage", + "phase": "commit", + "transactionId": "run-1:0", + "requestStatus": "SUCCESS", + "model": "claude-sonnet-4-5", + "requestType": "COMPLETION", + "usage": {"durationMs": 2500, "units": "2.5", "unitType": "second"}, + }, + "metadata": {"feature": "summaries"}, + } + + +@pytest.mark.parametrize("transaction_id", ["", "has space", "a" * 129, "slash/no"]) +def test_a_transaction_id_the_api_would_refuse_is_refused_before_sending( + kaiten: tuple[KaitenClient, FakeKaiten], transaction_id: str +) -> None: + client, fake = kaiten + with pytest.raises(ValueError, match="transaction id"): + client.instances.commit_ai_usage(INSTANCE, POOL, transaction_id=transaction_id) + assert fake.reports == [] + + +@pytest.mark.parametrize( + "usage", + [{"input_tokens": -1}, {"output_tokens": 1.5}, {"units": "-2"}, {"tokens": 3}], +) +def test_a_malformed_usage_is_refused_before_sending( + kaiten: tuple[KaitenClient, FakeKaiten], usage: Any +) -> None: + client, fake = kaiten + with pytest.raises(ValueError, match="usage"): + client.instances.reserve_ai_usage(INSTANCE, POOL, model="gpt-4o", usage=usage) + assert fake.reports == [] + + +# -- errors --------------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + ("status_code", "code", "expected"), + [ + (409, "ThresholdExceeded", InsufficientCreditsError), + (409, "TransactionIdReused", TransactionIdReusedError), + (422, "UnknownModel", UnknownModelError), + (422, "ModelNotAuthorized", ModelNotAuthorizedError), + (422, "AiUsageUnsupportedEntitlement", AiUsageUnsupportedError), + (422, "InvalidValueType", AiUsageUnsupportedError), + (422, None, AiUsageUnsupportedError), + ], +) +def test_each_refusal_raises_its_own_error( + kaiten: tuple[KaitenClient, FakeKaiten], + status_code: int, + code: str | None, + expected: type[Exception], +) -> None: + client, fake = kaiten + fake.refuse("reserve", problem(status_code, code)) + with pytest.raises(expected) as caught: + client.instances.reserve_ai_usage(INSTANCE, POOL, model="gpt-4o") + assert len(fake.reports) == 1, "a 4xx is never retried" + assert caught.value.__cause__ is not None + + +def test_running_out_of_credits_is_also_a_reached_threshold( + kaiten: tuple[KaitenClient, FakeKaiten], +) -> None: + client, fake = kaiten + fake.refuse("reserve", problem(409, "ThresholdExceeded")) + with pytest.raises(ThresholdExceededError) as caught: + client.instances.reserve_ai_usage(INSTANCE, POOL, model="gpt-4o") + assert isinstance(caught.value, ConflictError) + assert caught.value.code == "ReportEntitlementUsageMetric.ThresholdExceeded" + + +@pytest.mark.parametrize( + "code", ["InvalidAiUsage", "TransactionIdRequired", "AiUsageRequiresAppend"] +) +def test_a_malformed_ai_body_is_not_mistaken_for_an_old_server( + kaiten: tuple[KaitenClient, FakeKaiten], code: str +) -> None: + client, fake = kaiten + fake.refuse("commit", problem(422, code)) + with pytest.raises(UnprocessableEntityError) as caught: + client.instances.commit_ai_usage(INSTANCE, POOL, transaction_id="t1") + assert not isinstance(caught.value, AiUsageUnsupportedError) + + +# -- retries -------------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "failure", [status(500), status(502), status(503), status(504), network_error] +) +def test_a_commit_is_retried_under_one_key_and_one_transaction_id( + kaiten: tuple[KaitenClient, FakeKaiten], failure: Any +) -> None: + client, fake = kaiten + fake.refuse("commit", failure, failure) + client.instances.commit_ai_usage(INSTANCE, POOL, transaction_id="t1") + + assert len(fake.reports) == 3 + assert {report.key for report in fake.reports} == {fake.reports[0].key} + assert {report.transaction_id for report in fake.reports} == {"t1"} + + +def test_a_commit_gives_up_after_three_retries(kaiten: tuple[KaitenClient, FakeKaiten]) -> None: + client, fake = kaiten + fake.refuse("commit", *[status(503)] * 5) + with pytest.raises(ServiceUnavailableError): + client.instances.commit_ai_usage(INSTANCE, POOL, transaction_id="t1") + assert len(fake.reports) == 4 + + +@pytest.mark.parametrize("code", [408, 429, 501]) +def test_a_commit_is_not_retried_outside_its_statuses( + kaiten: tuple[KaitenClient, FakeKaiten], code: int +) -> None: + client, fake = kaiten + fake.refuse("commit", status(code)) + with pytest.raises(APIStatusError): + client.instances.commit_ai_usage(INSTANCE, POOL, transaction_id="t1") + assert len(fake.reports) == 1 + + +def test_each_logical_call_has_its_own_key(kaiten: tuple[KaitenClient, FakeKaiten]) -> None: + client, fake = kaiten + client.instances.commit_ai_usage(INSTANCE, POOL, transaction_id="t1") + client.instances.commit_ai_usage(INSTANCE, POOL, transaction_id="t1") + assert fake.reports[0].key != fake.reports[1].key + + +def test_the_client_retry_setting_does_not_change_the_ai_policy( + make_client: Callable[..., Any], +) -> None: + fake = FakeKaiten() + client, _ = make_client(fake, max_retries=0) + fake.refuse("reserve", status(503)) + client.instances.reserve_ai_usage(INSTANCE, POOL, model="gpt-4o") + assert len(fake.reports) == 2 + + +# -- the module functions -------------------------------------------------------------------- + + +def test_the_module_phases_go_through_the_client(kaiten: tuple[KaitenClient, FakeKaiten]) -> None: + client, fake = kaiten + reserved = ai.reserve(client, instance=INSTANCE, pool=POOL, model="gpt-4o") + assert reserved.ai is not None + ai.commit( + client, + instance=INSTANCE, + pool=POOL, + transaction_id=reserved.ai.transaction_id, + request_status="NOT_SENT", + ) + assert fake.phases == [ + ("reserve", reserved.ai.transaction_id), + ("commit", reserved.ai.transaction_id), + ] + assert fake.commits[0]["requestStatus"] == "NOT_SENT" + + +def test_run_reserves_calls_and_commits_the_usage(kaiten: tuple[KaitenClient, FakeKaiten]) -> None: + client, fake = kaiten + calls: list[str] = [] + + def call() -> dict[str, Any]: + calls.append("provider") + return {"usage": {"prompt_tokens": 120, "completion_tokens": 30}} + + result = ai.run( + client, + call, + instance=INSTANCE, + pool=POOL, + model="gpt-4o", + estimate={"input_tokens": 100, "output_tokens": 1024}, + usage=lambda reply: ai.from_openai(reply["usage"]), + transaction_id="op-1", + ) + + assert calls == ["provider"] + assert result["usage"]["prompt_tokens"] == 120 + assert fake.phases == [("reserve", "op-1"), ("commit", "op-1")] + assert fake.reserves[0]["usage"] == {"inputTokens": 100, "outputTokens": 1024} + assert fake.commits[0] == { + "type": "ai_usage", + "phase": "commit", + "transactionId": "op-1", + "requestStatus": "SUCCESS", + "usage": {"inputTokens": 120, "outputTokens": 30}, + } + + +def test_run_does_not_call_the_provider_when_the_pool_is_empty( + kaiten: tuple[KaitenClient, FakeKaiten], +) -> None: + client, fake = kaiten + fake.refuse("reserve", problem(409, "ThresholdExceeded")) + called: list[bool] = [] + with pytest.raises(InsufficientCreditsError): + ai.run(client, lambda: called.append(True), instance=INSTANCE, pool=POOL, model="gpt-4o") + assert called == [] + assert fake.commits == [] + + +class ProviderError(Exception): + def __init__(self, status_code: int) -> None: + super().__init__(f"HTTP {status_code}") + self.status_code = status_code + + +@pytest.mark.parametrize( + ("error", "expected"), + [ + (ConnectionRefusedError("refused"), "NOT_SENT"), + (TypeError("possibly raised after sending"), "PROVIDER_ERROR"), + (httpx.ConnectError("connect"), "NOT_SENT"), + (httpx.ConnectTimeout("connect"), "NOT_SENT"), + (httpx.PoolTimeout("pool"), "NOT_SENT"), + (httpx.ReadTimeout("read"), "PROVIDER_ERROR"), + (httpx.ReadError("reset after send"), "PROVIDER_ERROR"), + (httpx.WriteError("partial write"), "PROVIDER_ERROR"), + (httpx.RemoteProtocolError("invalid response"), "PROVIDER_ERROR"), + (RuntimeError("unknown failure"), "PROVIDER_ERROR"), + (ProviderError(500), "PROVIDER_ERROR"), + (ProviderError(429), "PROVIDER_ERROR"), + (ProviderError(400), "CUSTOMER_ERROR"), + ], +) +def test_run_commits_how_a_failed_call_ended_and_reraises( + kaiten: tuple[KaitenClient, FakeKaiten], error: Exception, expected: str +) -> None: + client, fake = kaiten + + def call() -> None: + raise error + + with pytest.raises(type(error)): + ai.run(client, call, instance=INSTANCE, pool=POOL, model="gpt-4o") + assert [commit["requestStatus"] for commit in fake.commits] == [expected] + + +def test_run_survives_a_commit_that_keeps_failing( + kaiten: tuple[KaitenClient, FakeKaiten], caplog: pytest.LogCaptureFixture +) -> None: + client, fake = kaiten + fake.refuse("commit", *[status(503)] * 4) + with caplog.at_level(logging.ERROR, logger="kaitencloud.ai"): + result = ai.run(client, lambda: "reply", instance=INSTANCE, pool=POOL, model="gpt-4o") + assert result == "reply" + assert len(fake.commits) == 4 + assert "reservation will expire" in caplog.text + + +@pytest.mark.parametrize("asynchronous", [False, True]) +@pytest.mark.parametrize("invalid_usage", [False, True]) +@pytest.mark.parametrize("settlement_fails", [False, True]) +def test_run_preserves_success_when_usage_extraction_or_validation_fails( + make_client: Callable[..., Any], + make_async_client: Callable[..., Any], + caplog: pytest.LogCaptureFixture, + asynchronous: bool, + invalid_usage: bool, + settlement_fails: bool, +) -> None: + fake = FakeKaiten() + if settlement_fails: + fake.refuse("commit", *[status(503)] * 4) + reply = object() + + def usage(result: Any) -> Any: + assert result is reply + if invalid_usage: + return {"input_tokens": -1} + raise ValueError("normalizer failed") + + async def scenario() -> Any: + client, _ = make_async_client(fake) + async with client: + + async def call() -> Any: + return reply + + return await ai.run( + client, call, instance=INSTANCE, pool=POOL, model="gpt-4o", usage=usage + ) + + with caplog.at_level(logging.ERROR, logger="kaitencloud.ai"): + if asynchronous: + result = asyncio.run(scenario()) + else: + client, _ = make_client(fake) + with client: + result = ai.run( + client, + lambda: reply, + instance=INSTANCE, + pool=POOL, + model="gpt-4o", + usage=usage, + ) + + assert result is reply + assert len(fake.reserves) == 1 + assert len(fake.commits) == (4 if settlement_fails else 1) + assert all(commit["requestStatus"] == "SUCCESS" for commit in fake.commits) + assert all("usage" not in commit for commit in fake.commits) + assert {commit["transactionId"] for commit in fake.commits} == { + fake.reserves[0]["transactionId"] + } + assert "attempting settlement without usage" in caplog.text + if settlement_fails: + assert "reservation will expire" in caplog.text + + +def test_a_timeout_class_name_does_not_prove_the_call_was_not_sent() -> None: + class ConnectTimeoutError(Exception): + pass + + assert ai.request_status_of(ConnectTimeoutError()) == "PROVIDER_ERROR" + + +def test_run_lets_the_call_through_a_server_without_ai_credits( + kaiten: tuple[KaitenClient, FakeKaiten], caplog: pytest.LogCaptureFixture +) -> None: + client, fake = kaiten + fake.refuse("reserve", problem(422, None)) + with caplog.at_level(logging.ERROR, logger="kaitencloud.ai"): + result = ai.run(client, lambda: "reply", instance=INSTANCE, pool=POOL, model="gpt-4o") + assert result == "reply" + assert fake.phases == [("reserve", fake.reports[0].transaction_id)] + assert "NOT being reported" in caplog.text + + +def test_a_meter_logs_an_unsupported_server_once_and_stops_reporting( + kaiten: tuple[KaitenClient, FakeKaiten], caplog: pytest.LogCaptureFixture +) -> None: + client, fake = kaiten + fake.refuse("reserve", problem(422, "AiUsageUnsupportedEntitlement")) + meter = ai.Meter(client, instance=INSTANCE, pool=POOL) + with caplog.at_level(logging.ERROR, logger="kaitencloud.ai"): + for _ in range(3): + assert meter.run(lambda: "reply", model="gpt-4o") == "reply" + assert len(fake.reports) == 1 + assert caplog.text.count("NOT being reported") == 1 + + +def test_the_async_client_runs_the_same_way(make_async_client: Callable[..., Any]) -> None: + fake = FakeKaiten() + + async def scenario() -> str: + client, _ = make_async_client(fake) + async with client: + + async def call() -> str: + return "reply" + + reserved = await ai.reserve(client, instance=INSTANCE, pool=POOL, model="gpt-4o") + assert reserved.ai is not None + await ai.commit( + client, instance=INSTANCE, pool=POOL, transaction_id=reserved.ai.transaction_id + ) + return await ai.run( + client, + call, + instance=INSTANCE, + pool=POOL, + model="gpt-4o", + usage=lambda _: {"output_tokens": 3}, + ) + + assert asyncio.run(scenario()) == "reply" + assert [phase for phase, _ in fake.phases] == ["reserve", "commit", "reserve", "commit"] + assert fake.commits[-1]["usage"] == {"outputTokens": 3} + + +def test_an_async_client_is_metered_by_an_async_meter() -> None: + client = AsyncKaitenClient(token="ksh_x", base_url="https://kaiten.test") + meter = ai.AsyncMeter(client, instance=INSTANCE, pool=POOL) + assert repr(meter) == "" + + +# -- transaction ids, normalisers, estimation ---------------------------------------------- + + +def test_transaction_ids_are_uuid7_and_sort_by_time() -> None: + ids = [ai.new_transaction_id() for _ in range(50)] + assert all(uuid.UUID(value).version == 7 for value in ids) + assert all(uuid.UUID(value).variant == uuid.RFC_4122 for value in ids) + assert len(set(ids)) == 50 + assert [value[:8] for value in ids] == sorted(value[:8] for value in ids) + + +class Usage: + """A provider usage object: attributes, not keys.""" + + def __init__(self, **fields: Any) -> None: + self.__dict__.update(fields) + + +@pytest.mark.parametrize( + ("usage", "expected"), + [ + # Chat Completions: prompt_tokens includes the cached and the cache-written tokens. + ( + Usage( + prompt_tokens=1000, + completion_tokens=500, + prompt_tokens_details=Usage(cached_tokens=300, cache_write_tokens=100), + completion_tokens_details=Usage(reasoning_tokens=200), + ), + { + "input_tokens": 600, + "cache_read_input_tokens": 300, + "cache_write_input_tokens": 100, + "output_tokens": 500, + }, + ), + # Responses API, as a dictionary. + ( + { + "input_tokens": 80, + "input_tokens_details": {"cached_tokens": 50, "cache_write_tokens": 0}, + "output_tokens": 20, + "output_tokens_details": {"reasoning_tokens": 5}, + }, + {"input_tokens": 30, "cache_read_input_tokens": 50, "output_tokens": 20}, + ), + # Embeddings: no output. + (Usage(prompt_tokens=12, total_tokens=12), {"input_tokens": 12, "output_tokens": 0}), + # Images. + ( + Usage(input_tokens=50, output_tokens=4160, input_tokens_details=Usage(image_tokens=0)), + {"input_tokens": 50, "output_tokens": 4160}, + ), + # A transcription billed by duration. + ({"type": "duration", "seconds": 12.5}, {"duration_ms": 12500}), + # Inconsistent counts clamp at zero. + ( + {"prompt_tokens": 10, "prompt_tokens_details": {"cached_tokens": 40}}, + {"input_tokens": 0, "cache_read_input_tokens": 40, "output_tokens": 0}, + ), + (None, {}), + ], +) +def test_from_openai_makes_the_token_counts_disjoint(usage: Any, expected: Any) -> None: + assert ai.from_openai(usage) == expected + + +@pytest.mark.parametrize( + ("usage", "expected"), + [ + ( + Usage( + input_tokens=40, + cache_read_input_tokens=1000, + cache_creation_input_tokens=200, + output_tokens=300, + ), + { + "input_tokens": 40, + "cache_read_input_tokens": 1000, + "cache_write_input_tokens": 200, + "output_tokens": 300, + }, + ), + ( + {"input_tokens": 5, "output_tokens": 1, "cache_read_input_tokens": None}, + {"input_tokens": 5, "output_tokens": 1}, + ), + (None, {}), + ], +) +def test_from_anthropic_maps_one_to_one(usage: Any, expected: Any) -> None: + assert ai.from_anthropic(usage) == expected + + +def test_usages_add_up_field_by_field() -> None: + assert ai.add_usage( + {"input_tokens": 3, "output_tokens": 1}, + None, + {"output_tokens": 4, "units": "1.5", "unit_type": "second"}, + {"units": 2}, + ) == {"input_tokens": 3, "output_tokens": 5, "units": "3.5", "unit_type": "second"} + + +def request(operation: str, request_type: Any = "COMPLETION", **params: Any) -> ai.AiRequest: + return ai.AiRequest("openai", operation, "gpt-4o", request_type, params) + + +def test_the_default_estimate_is_prompt_bytes_over_four_and_the_output_ceiling() -> None: + messages = [{"role": "user", "content": "x" * 397}] # 432 bytes, serialized + estimate = ai.default_estimator( + request("chat.completions.create", messages=messages, max_completion_tokens=800) + ) + assert estimate == {"input_tokens": 108, "output_tokens": 800} + + +def test_without_a_ceiling_the_output_defaults_per_request_type() -> None: + assert ai.default_estimator(request("responses.create", input="hello")) == { + "input_tokens": 3, + "output_tokens": 1024, + } + assert ai.default_estimator(request("embeddings.create", "EMBEDDING", input=["a", "b"])) == { + "input_tokens": 3, + "output_tokens": 0, + } + assert ai.default_estimator(request("audio.transcriptions.create", "AUDIO")) == {} + + +def test_an_image_generation_reserves_its_images() -> None: + assert ai.default_estimator(request("images.generate", "IMAGE_GENERATION", n=3)) == { + "units": 3, + "unit_type": "image", + } + assert ai.default_estimator(request("images.generate", "IMAGE_GENERATION")) == { + "units": 1, + "unit_type": "image", + } + + +@pytest.mark.parametrize( + ("timeout", "expected"), + [ + (600.0, 600), + (12.2, 13), + (httpx.Timeout(30.0, connect=10.0), 30), + (httpx.Timeout(None), None), + (7200, 3600), + (None, None), + (0, None), + (True, None), + ], +) +def test_a_reservation_lasts_as_long_as_the_provider_timeout(timeout: Any, expected: Any) -> None: + from kaitencloud._ai import ttl_from_timeout + + assert ttl_from_timeout(timeout) == expected diff --git a/tests/test_contract.py b/tests/test_contract.py index 6331982..242e787 100644 --- a/tests/test_contract.py +++ b/tests/test_contract.py @@ -31,7 +31,7 @@ from typing import Any import pytest -from contract import contract_api, find_operation, write_errors +from contract import contract_api, find_operation, operations, write_errors from helpers import ORG_ID, USER_ID, Recorder, body from kaitencloud import targeting @@ -723,3 +723,54 @@ def test_every_mapped_operation_has_a_contract_case() -> None: for surface in ("core", "platform"): mapped = set(coverage()[surface]["operations"]) assert mapped - covered == set(), f"{surface} operations without a contract case" + + +def _read_schemas(surface: str) -> dict[str, Any]: + """Every schema a response or a webhook payload of the contract can reach, by name.""" + from contract import document + + spec = document(surface) + schemas = spec["components"]["schemas"] + pending: list[Any] = [ + response + for operation in operations(surface) + for code, response in operation.definition["responses"].items() + if code.startswith("2") + ] + pending.append(spec.get("webhooks") or {}) + reached: dict[str, Any] = {} + while pending: + node = pending.pop() + if isinstance(node, dict): + ref = node.get("$ref") + if isinstance(ref, str) and ref.startswith("#/components/schemas/"): + name = ref.rsplit("/", 1)[-1] + if name not in reached: + reached[name] = schemas[name] + pending.append(schemas[name]) + pending.extend(node.values()) + elif isinstance(node, list): + pending.extend(node) + return reached + + +def _mentions(node: Any, needle: str) -> bool: + if isinstance(node, dict): + return any(_mentions(value, needle) for value in node.values()) + if isinstance(node, list): + return any(_mentions(value, needle) for value in node) + return node == needle + + +@pytest.mark.parametrize("surface", ["core", "platform"]) +def test_no_read_schema_carries_the_write_only_ai_usage_variant(surface: str) -> None: + """``ai_usage`` is a request value only: a response carrying it would break strict decoding. + + ``EntitlementValue`` -- what every usage answer's ``value`` and ``limit`` decode into -- is + a closed union, so a released SDK would refuse an answer whose value were ``ai_usage``. + """ + leaking = sorted( + name for name, schema in _read_schemas(surface).items() if _mentions(schema, "ai_usage") + ) + assert leaking == [] + assert "EntitlementUsage" in _read_schemas("core") or surface == "platform" From 8c9fb5a3ec797ad6beb8474b4a05b1706d076190 Mon Sep 17 00:00:00 2001 From: tomflenner Date: Mon, 19 Oct 2026 10:42:12 +0200 Subject: [PATCH 2/8] feat(ai): wrap optional provider clients with streaming settlement Signed-off-by: tomflenner --- .github/workflows/ci.yml | 13 +- CHANGELOG.md | 6 + CONTRIBUTING.md | 8 + README.md | 13 + internal-docs/AI_WRAPPERS.md | 103 +++++++ pyproject.toml | 5 + src/kaitencloud/_async/providers.py | 250 ++++++++++++++++ src/kaitencloud/_provider_clients.py | 83 +++++ src/kaitencloud/_provider_usage.py | 106 +++++++ src/kaitencloud/_sync/providers.py | 248 +++++++++++++++ src/kaitencloud/_version.py | 2 +- src/kaitencloud/anthropic.py | 39 +++ src/kaitencloud/openai.py | 39 +++ tests/test_provider_compatibility.py | 258 ++++++++++++++++ tests/test_providers.py | 433 +++++++++++++++++++++++++++ uv.lock | 259 +++++++++++++++- 16 files changed, 1860 insertions(+), 5 deletions(-) create mode 100644 internal-docs/AI_WRAPPERS.md create mode 100644 src/kaitencloud/_async/providers.py create mode 100644 src/kaitencloud/_provider_clients.py create mode 100644 src/kaitencloud/_provider_usage.py create mode 100644 src/kaitencloud/_sync/providers.py create mode 100644 src/kaitencloud/anthropic.py create mode 100644 src/kaitencloud/openai.py create mode 100644 tests/test_provider_compatibility.py create mode 100644 tests/test_providers.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0514835..259be36 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -56,6 +56,17 @@ jobs: - run: uv sync --locked - run: uv run pytest --cov --cov-report=term-missing + provider-compatibility: + name: test (provider SDKs) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: astral-sh/setup-uv@v6 + with: + python-version: "3.10" + - run: uv sync --locked --extra openai --extra anthropic + - run: uv run pytest tests/test_providers.py tests/test_provider_compatibility.py + lowest-dependencies: name: test (lowest dependencies) runs-on: ubuntu-latest @@ -77,7 +88,7 @@ jobs: test: name: test if: always() - needs: [tests, lowest-dependencies] + needs: [tests, lowest-dependencies, provider-compatibility] runs-on: ubuntu-latest steps: - name: Every test job passed diff --git a/CHANGELOG.md b/CHANGELOG.md index ca053cd..f465553 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,12 @@ public API, a minor version adds to it, and a patch version fixes it. ### Added +- Optional OpenAI and Anthropic drop-in clients and existing-client wrappers, with + allow-listed reserve/call/commit accounting, sync/async streaming settlement, + pluggable estimates and provider timeout-based reservation lifetimes. +- Documented current agent SDK accounting interfaces and the missing supported + per-model-inference gating boundary; no misleading run-budget wrappers. + - `feature_flags.targeting_context()`, `feature_flags.lint_rule()` and `feature_flags.test_rule()`: the facts a targeting rule can read, the check the API runs before accepting one, and a dry run of a rule against a customer or an instance. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index dccb2fb..e271355 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -86,3 +86,11 @@ the async source, the contract or a generator, then run `task generate`. Do not report unpatched vulnerabilities in public issues. See [SECURITY.md](./SECURITY.md). + +## Optional AI provider compatibility + +Provider packages are optional extras, not core dependencies. Their supported +major ranges are declared in `pyproject.toml`; CI checks synthetic usage and +in-process mock transports, never real provider calls. See +[`internal-docs/AI_WRAPPERS.md`](internal-docs/AI_WRAPPERS.md) for the bounded +surface and verified agent SDK constraints. diff --git a/README.md b/README.md index 57b58b6..53947f0 100644 --- a/README.md +++ b/README.md @@ -835,3 +835,16 @@ By contributing, you agree to certify your contribution under the The Kaiten name and logos are not licensed under Apache-2.0. See the Kaiten trademark policy in the main Kaiten repository. + +### AI credit provider wrappers + +Use `from kaitencloud.openai import OpenAI` or +`from kaitencloud.anthropic import Anthropic`, then pass +`kaiten=client, instance="acme-prod", pool="chat-credits"` alongside your normal +provider options. Async drop-in classes and `wrap_openai` / `wrap_anthropic` +for existing clients are available. Install the corresponding `openai` or +`anthropic` extra. Every allow-listed call reserves before execution and settles +with normalized usage; pricing stays on the server. + +See [AI wrapper behavior and limits](internal-docs/AI_WRAPPERS.md), especially +stream closure, missing audio usage and agent SDK gating constraints. diff --git a/internal-docs/AI_WRAPPERS.md b/internal-docs/AI_WRAPPERS.md new file mode 100644 index 0000000..d6177d8 --- /dev/null +++ b/internal-docs/AI_WRAPPERS.md @@ -0,0 +1,103 @@ +# AI credit wrappers + +The server prices every call. The SDK sends only model, request type and usage; +no prompts, responses or vendor prices are sent to Kaiten. + +## Provider clients + +Install `kaitencloud[openai]` (OpenAI 3.x, >=3.35.1) or +`kaitencloud[anthropic]` (Anthropic 1.x, >=1.15.0). + +```python +from kaitencloud.openai import OpenAI + +provider = OpenAI( + api_key="your-provider-key", kaiten=kaiten, instance="acme-prod", pool="chat-credits" +) +reply = provider.chat.completions.create(model="your-model", messages=messages) +``` + +`AsyncOpenAI`, `Anthropic` and `AsyncAnthropic` use the same keyword options. +Provider and Kaiten clients must both be sync or both be async. An existing +client can be delegated with `wrap_openai(client, kaiten=..., instance=..., pool=...)` +or `wrap_anthropic(...)`. The wrapper does not own a supplied client's lifetime; +`close()` and context-manager exit delegate to that client. Copies made with +`copy()` / `with_options()` retain the meter. + +Every metered call requires an explicit model. `estimator=` receives an +`AiRequest` and returns an optional usage estimate; by default serialized prompt +bytes/4 and the declared output ceiling (otherwise 1024 for completions) are +reserved. Reservation TTL follows the request/client timeout when available. + +### Bounded surface + +OpenAI: `chat.completions.create`, `responses.create`, `embeddings.create`, +`images.generate`, `audio.transcriptions.create`, `audio.speech.create`. +Anthropic: `messages.create`, `messages.stream`. + +Unlisted methods/resources stay provider-owned, including raw/streaming HTTP +response helpers, parsing helpers, batches, realtime and tool runners. They are +not metered: use generic `kaitencloud.ai.run` when you need explicit accounting. + +A refused reserve raises its typed error before the provider is called. An +unsupported server is logged once per meter and subsequent calls pass through +unreported. Settlement is best effort under the original transaction ID; a +failed commit is logged, never substituted for the provider result. + +### Streams + +Consume streams fully or close them explicitly; context managers are strongly +recommended. A bare `break` on a retained stream does not close it. Garbage +collection cannot reliably perform asynchronous settlement. + +Chat streaming merges `stream_options.include_usage=True`. Responses uses the +terminal response usage without adding chat-only parameters. Anthropic starts +with `message_start` usage and overwrites cumulative fields from `message_delta` +(never sums these snapshots). Its `messages.stream()` context-manager factory, +`text_stream`, `until_done` and final-message helpers are preserved. + +Settlement occurs once on completion, iteration failure or close. An abandoned +stream with received events reports `CUSTOMER_ERROR` and known usage; a stream +closed before receiving anything reports `NOT_SENT`, following the V1 wrapper +policy. Network/protocol failures report `PROVIDER_ERROR` unless the failure is +proven to precede sending. Image partial frames are not additional images; +completed images and actual returned image count supply units. Speech binary +responses and transcription text formats may expose no usage; these settle +without usage and the server applies its minimum/warning policy. Never infer +billable duration from compressed audio bytes. + +Compatibility tests use actual optional SDKs and in-process mock transports, +never provider endpoints or credentials. + +## Agent SDK constraints (survey 2026-10-19) + +Do not mistake tool hooks or user-facing runs for model-inference boundaries. +No fake agent wrapper is shipped until it can satisfy the per-model-turn gate. + +* Claude Agent (`claude-agent-sdk` 0.2.168, Python >=3.10): + `AssistantMessage` exposes `model`, `usage`, `message_id` and subagent context; + partial `StreamEvent` exposes raw Anthropic events. `ResultMessage` usage is + aggregate. Public `HookEvent` includes tool/user/stop/subagent hooks but no + before-every-model-call hook. A `PreToolUse` callback runs after inference. + [Types](https://github.com/anthropics/claude-agent-sdk-python/blob/main/src/claude_agent_sdk/types.py). +* Codex (`openai-codex` 0.163.0, Python >=3.10): `Thread.run` / `turn` and sync/async + handles are available. `thread/tokenUsage/updated` carries `thread_id`, + `turn_id`, `token_usage.last` and `total`, but no model field. One user-facing + turn may include multiple model calls. No awaited per-model-call veto hook is + established by the public API. + [API](https://github.com/openai/codex/blob/main/sdk/python/docs/api-reference.md), + [usage types](https://github.com/openai/codex/blob/main/sdk/python/src/openai_codex/generated/v2_all.py). +* Copilot (`github-copilot-sdk` 1.0.22, Python >=3.11): `AssistantUsageData` exposes + model, input/output/cache/reasoning counts; turn events and `agent_id` support + attribution. `session.on(callback)` is synchronous; dispatcher exceptions are + logged and do not stop inference. Session hooks expose no awaited per-model + gate. Cache inclusion semantics need verification before normalizing usage. + Runtime startup may auto-download binaries, so compatibility tests must not + start real sessions. + [Events](https://github.com/github/copilot-sdk/blob/main/python/copilot/generated/session_events.py), + [callbacks/hooks](https://github.com/github/copilot-sdk/blob/main/python/copilot/session.py). + +Post-call accounting adapters are possible, but cannot honestly promise the +required preflight for each internal model turn or subagent. Integration needs +a supported veto-capable model boundary or an explicitly approved scope change; +run-level budgets, private monkeypatches and hosted proxies are not substitutes. diff --git a/pyproject.toml b/pyproject.toml index e7a85b4..ef8f269 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,6 +42,10 @@ classifiers = [ "Typing :: Typed", ] +[project.optional-dependencies] +openai = ["openai>=3.35.1,<4"] +anthropic = ["anthropic>=1.15.0,<2"] + [project.urls] Homepage = "https://kaiten.sh" Documentation = "https://docs.kaiten.sh" @@ -75,6 +79,7 @@ include = [ "/examples", "/tests", "/openapi", + "/internal-docs", "/scripts", "/README.md", "/CHANGELOG.md", diff --git a/src/kaitencloud/_async/providers.py b/src/kaitencloud/_async/providers.py new file mode 100644 index 0000000..22250e3 --- /dev/null +++ b/src/kaitencloud/_async/providers.py @@ -0,0 +1,250 @@ +# Copyright 2026 KAITEN INC +# SPDX-License-Identifier: Apache-2.0 + +"""Allow-listed provider delegation and asynchronous stream settlement.""" + +from __future__ import annotations + +import logging +from collections.abc import AsyncIterator, Callable +from types import TracebackType +from typing import Any + +from .._ai import AiRequest, request_status_of, ttl_from_timeout +from .._provider_usage import ANTHROPIC_METHODS, OPENAI_METHODS, UsageSnapshot, get +from ..types import AiRequestStatus +from .ai import AsyncMeter + +log = logging.getLogger("kaitencloud.ai") + + +class AsyncProvider: + """A provider-owned client/resource, with only its bounded usage methods intercepted.""" + + def __init__( + self, client: Any, meter: AsyncMeter, provider: str, *, path: str = "", root: Any = None + ) -> None: + self._client = client + self._meter = meter + self._provider = provider + self._path = path + self._root = client if root is None else root + self._methods = OPENAI_METHODS if provider == "openai" else ANTHROPIC_METHODS + + def __getattr__(self, name: str) -> Any: + target = getattr(self._client, name) + path = f"{self._path}.{name}" if self._path else name + if not self._path and name in ("copy", "with_options"): + + def copy(*args: Any, **kwargs: Any) -> AsyncProvider: + return AsyncProvider(target(*args, **kwargs), self._meter, self._provider) + + return copy + if path in self._methods: + if path == "messages.stream": + + def manager(**kwargs: Any) -> AsyncProviderStreamManager: + return AsyncProviderStreamManager(self, target, path, kwargs) + + return manager + + async def call(**kwargs: Any) -> Any: + transaction = await self._reserve(path, kwargs) + if transaction is None: + return await target(**kwargs) + if path == "chat.completions.create" and kwargs.get("stream"): + kwargs = { + **kwargs, + "stream_options": { + **(kwargs.get("stream_options") or {}), + "include_usage": True, + }, + } + try: + result = await target(**kwargs) + except BaseException as error: + await self._meter.commit(transaction, request_status=request_status_of(error)) + raise + snapshot = UsageSnapshot(self._provider, path) + if kwargs.get("stream"): + return AsyncProviderStream(result, self._meter, transaction, snapshot) + try: + snapshot.result(result) + except Exception: + log.exception( + "Kaiten AI usage: provider usage extraction failed; settling without usage" + ) + await self._meter.commit(transaction, usage=snapshot.usage) + return result + + return call + if any(method.startswith(path + ".") for method in self._methods): + return AsyncProvider(target, self._meter, self._provider, path=path, root=self._root) + return target + + async def _reserve(self, path: str, kwargs: dict[str, Any]) -> str | None: + if self._meter.unsupported: + return None + model = kwargs.get("model") + if not isinstance(model, str) or not model: + raise ValueError("a metered provider call requires an explicit model") + request = AiRequest(self._provider, path, model, self._methods[path], kwargs) + return await self._meter.reserve( + model=model, + request_type=request.request_type, + usage=self._meter.estimate(request), + ttl_seconds=ttl_from_timeout(kwargs.get("timeout", get(self._root, "timeout"))), + ) + + async def __aenter__(self) -> AsyncProvider: + await self._client.__aenter__() + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + traceback: TracebackType | None, + ) -> Any: + return await self._client.__aexit__(exc_type, exc, traceback) + + +class AsyncProviderStream: + """Delegated stream with exactly one best-effort settlement on consumption/close.""" + + def __init__( + self, stream: Any, meter: AsyncMeter, transaction: str, snapshot: UsageSnapshot + ) -> None: + self._stream = stream + self._iterator = stream.__aiter__() + self._meter = meter + self._transaction = transaction + self._snapshot = snapshot + self._settled = False + + def __getattr__(self, name: str) -> Any: + return getattr(self._stream, name) + + def __aiter__(self) -> AsyncIterator[Any]: + return self + + async def __anext__(self) -> Any: + try: + event = await self._iterator.__anext__() + except StopAsyncIteration: + await self._settle("PROVIDER_ERROR" if self._snapshot.failed else "SUCCESS") + raise + except BaseException as error: + await self._settle(request_status_of(error)) + raise + try: + self._snapshot.event(event) + except Exception: + log.exception("Kaiten AI usage: stream usage extraction failed; keeping known usage") + if self._snapshot.completed: + await self._settle("PROVIDER_ERROR" if self._snapshot.failed else "SUCCESS") + return event + + async def _settle(self, status: AiRequestStatus) -> None: + if not self._settled: + self._settled = True + await self._meter.commit( + self._transaction, request_status=status, usage=self._snapshot.usage + ) + + async def close(self) -> None: + try: + await self._stream.close() + finally: + status: AiRequestStatus = ( + "SUCCESS" + if self._snapshot.completed + else "CUSTOMER_ERROR" + if self._snapshot.received + else "NOT_SENT" + ) + await self._settle(status) + + async def __aenter__(self) -> AsyncProviderStream: + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + traceback: TracebackType | None, + ) -> None: + await self.close() + + @property + def text_stream(self) -> AsyncIterator[str]: + async def texts() -> AsyncIterator[str]: + async for event in self: + kind = get(event, "type") + delta = get(event, "delta") + if kind == "text": + yield get(event, "text", "") + elif ( + self._snapshot.operation != "messages.stream" + and kind == "content_block_delta" + and get(delta, "type") == "text_delta" + ): + yield get(delta, "text", "") + + return texts() + + async def until_done(self) -> None: + async for _ in self: + pass + + async def get_final_message(self) -> Any: + await self.until_done() + return await self._stream.get_final_message() + + async def get_final_text(self) -> str: + message = await self.get_final_message() + return "".join(get(block, "text", "") for block in get(message, "content", [])) + + +class AsyncProviderStreamManager: + """Anthropic's stream factory is synchronous, opening the network on async entry.""" + + def __init__( + self, provider: AsyncProvider, target: Callable[..., Any], path: str, kwargs: dict[str, Any] + ) -> None: + self._provider = provider + self._target = target + self._path = path + self._kwargs = kwargs + self._manager: Any = None + self._stream: AsyncProviderStream | None = None + + async def __aenter__(self) -> Any: + transaction = await self._provider._reserve(self._path, self._kwargs) + try: + self._manager = self._target(**self._kwargs) + stream = await self._manager.__aenter__() + except BaseException as error: + if transaction is not None: + await self._provider._meter.commit( + transaction, request_status=request_status_of(error) + ) + raise + if transaction is None: + return stream + self._stream = AsyncProviderStream( + stream, self._provider._meter, transaction, UsageSnapshot("anthropic", self._path) + ) + return self._stream + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + traceback: TracebackType | None, + ) -> Any: + try: + return await self._manager.__aexit__(exc_type, exc, traceback) + finally: + if self._stream is not None: + await self._stream.close() diff --git a/src/kaitencloud/_provider_clients.py b/src/kaitencloud/_provider_clients.py new file mode 100644 index 0000000..94f1e00 --- /dev/null +++ b/src/kaitencloud/_provider_clients.py @@ -0,0 +1,83 @@ +# Copyright 2026 KAITEN INC +# SPDX-License-Identifier: Apache-2.0 + +"""Optional provider entry points, imported only when a drop-in class is requested.""" + +from __future__ import annotations + +import importlib +from typing import Any + +from ._ai import Estimator +from ._async.ai import AsyncMeter +from ._async.client import AsyncKaitenClient +from ._async.providers import AsyncProvider +from ._sync.ai import Meter +from ._sync.client import KaitenClient +from ._sync.providers import Provider + + +def wrap( + client: Any, + kaiten: KaitenClient | AsyncKaitenClient, + provider: str, + *, + instance: str, + pool: str, + estimator: Estimator | None = None, +) -> Any: + if isinstance(kaiten, AsyncKaitenClient): + return AsyncProvider( + client, AsyncMeter(kaiten, instance=instance, pool=pool, estimator=estimator), provider + ) + return Provider( + client, Meter(kaiten, instance=instance, pool=pool, estimator=estimator), provider + ) + + +def drop_in(provider: str, name: str, module: str) -> type[Any]: + try: + base = getattr(importlib.import_module(provider), name) + except ImportError as error: + raise ImportError(f"Install kaitencloud[{provider}] to use {module}.{name}") from error + + def init( + self: Any, + *args: Any, + kaiten: KaitenClient | AsyncKaitenClient | None = None, + instance: str | None = None, + pool: str | None = None, + estimator: Estimator | None = None, + **kwargs: Any, + ) -> None: + if kaiten is not None and (instance is None or pool is None): + raise ValueError("instance and pool are required with kaiten") + if kaiten is not None and name.startswith("Async") != isinstance(kaiten, AsyncKaitenClient): + raise TypeError( + "provider and Kaiten clients must both be synchronous or both asynchronous" + ) + base.__init__(self, *args, **kwargs) + if kaiten is not None: + assert instance is not None + assert pool is not None + proxy = wrap(self, kaiten, provider, instance=instance, pool=pool, estimator=estimator) + roots = ( + ("chat", "responses", "embeddings", "images", "audio") + if provider == "openai" + else ("messages",) + ) + resources = {root: getattr(proxy, root) for root in roots} + for root, resource in resources.items(): + setattr(self, root, resource) + # Copies must carry the existing pool meter rather than silently losing metering. + for method in ("copy", "with_options"): + original = getattr(base, method, None) + if original is not None: + + def copied(*args: Any, _original: Any = original, **kwargs: Any) -> Any: + copied_client = _original(self, *args, **kwargs) + return type(proxy)(copied_client, proxy._meter, provider) + + setattr(self, method, copied) + + return type(name, (base,), {"__init__": init, "__module__": module}) diff --git a/src/kaitencloud/_provider_usage.py b/src/kaitencloud/_provider_usage.py new file mode 100644 index 0000000..7ff26f0 --- /dev/null +++ b/src/kaitencloud/_provider_usage.py @@ -0,0 +1,106 @@ +# Copyright 2026 KAITEN INC +# SPDX-License-Identifier: Apache-2.0 + +"""Provider usage snapshots, never prompts, prices or transport code.""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +from ._ai import add_usage, from_anthropic, from_openai +from .types import AiRequestType, AiUsageParam + +OPENAI_METHODS: dict[str, AiRequestType] = { + "chat.completions.create": "COMPLETION", + "responses.create": "COMPLETION", + "embeddings.create": "EMBEDDING", + "images.generate": "IMAGE_GENERATION", + "audio.transcriptions.create": "AUDIO", + "audio.speech.create": "AUDIO", +} +ANTHROPIC_METHODS: dict[str, AiRequestType] = { + "messages.create": "COMPLETION", + "messages.stream": "COMPLETION", +} + + +def get(value: Any, name: str, default: Any = None) -> Any: + return value.get(name, default) if isinstance(value, Mapping) else getattr(value, name, default) + + +class UsageSnapshot: + """Latest cumulative usage for one request; image completions are separate units.""" + + def __init__(self, provider: str, operation: str) -> None: + self.provider = provider + self.operation = operation + self.usage: AiUsageParam | None = None + self.received = False + self.completed = False + self.failed = False + self.images = 0 + + def result(self, result: Any) -> AiUsageParam | None: + usage = get(result, "usage") + if usage is not None: + self.usage = ( + from_anthropic(usage) if self.provider == "anthropic" else from_openai(usage) + ) + if self.operation == "images.generate": + images = get(result, "data") + if images is not None: + self.usage = {**(self.usage or {}), "units": len(images), "unit_type": "image"} + # verbose_json transcriptions expose duration directly, not under usage. + if self.operation == "audio.transcriptions.create" and usage is None: + duration = get(result, "duration") + if isinstance(duration, (int, float)) and not isinstance(duration, bool): + self.usage = {"duration_ms": max(round(duration * 1000), 0)} + return self.usage + + def event(self, event: Any) -> None: + self.received = True + kind = get(event, "type") + if self.provider == "anthropic": + if kind == "message_start": + self.result(get(event, "message")) + elif kind == "message_delta": + usage = get(event, "usage") + if usage is not None: + # Deltas contain cumulative totals; absent/null fields keep the start value. + fields = { + "input_tokens": "input_tokens", + "cache_read_input_tokens": "cache_read_input_tokens", + "cache_creation_input_tokens": "cache_write_input_tokens", + "output_tokens": "output_tokens", + } + merged = dict(self.usage or {}) + for source, target in fields.items(): + count = get(usage, source) + if count is not None: + merged[target] = count + self.usage = merged # type: ignore[assignment] + elif kind == "message_stop": + self.completed = True + elif kind == "error": + self.failed = True + return + if kind in ("response.completed", "response.incomplete", "response.failed"): + self.result(get(event, "response")) + self.completed = True + self.failed = kind == "response.failed" + elif kind == "image_generation.completed": + self.images += 1 + normalised = from_openai(get(event, "usage")) + self.usage = add_usage(self.usage, normalised) + self.usage["units"] = self.images + self.usage["unit_type"] = "image" + elif kind == "transcript.text.done": + self.result(event) + self.completed = True + else: + usage = get(event, "usage") + if usage is not None: + self.usage = from_openai(usage) + if kind == "error": + self.failed = True diff --git a/src/kaitencloud/_sync/providers.py b/src/kaitencloud/_sync/providers.py new file mode 100644 index 0000000..e10606c --- /dev/null +++ b/src/kaitencloud/_sync/providers.py @@ -0,0 +1,248 @@ +# Copyright 2026 KAITEN INC +# SPDX-License-Identifier: Apache-2.0 + +# Generated by scripts/unasync.py from src/kaitencloud/_async/providers.py. Do not edit by hand: +# change the async source, then run `task generate`. +"""Allow-listed provider delegation and asynchronous stream settlement.""" + +from __future__ import annotations + +import logging +from collections.abc import Callable, Iterator +from types import TracebackType +from typing import Any + +from .._ai import AiRequest, request_status_of, ttl_from_timeout +from .._provider_usage import ANTHROPIC_METHODS, OPENAI_METHODS, UsageSnapshot, get +from ..types import AiRequestStatus +from .ai import Meter + +log = logging.getLogger("kaitencloud.ai") + + +class Provider: + """A provider-owned client/resource, with only its bounded usage methods intercepted.""" + + def __init__( + self, client: Any, meter: Meter, provider: str, *, path: str = "", root: Any = None + ) -> None: + self._client = client + self._meter = meter + self._provider = provider + self._path = path + self._root = client if root is None else root + self._methods = OPENAI_METHODS if provider == "openai" else ANTHROPIC_METHODS + + def __getattr__(self, name: str) -> Any: + target = getattr(self._client, name) + path = f"{self._path}.{name}" if self._path else name + if not self._path and name in ("copy", "with_options"): + + def copy(*args: Any, **kwargs: Any) -> Provider: + return Provider(target(*args, **kwargs), self._meter, self._provider) + + return copy + if path in self._methods: + if path == "messages.stream": + + def manager(**kwargs: Any) -> ProviderStreamManager: + return ProviderStreamManager(self, target, path, kwargs) + + return manager + + def call(**kwargs: Any) -> Any: + transaction = self._reserve(path, kwargs) + if transaction is None: + return target(**kwargs) + if path == "chat.completions.create" and kwargs.get("stream"): + kwargs = { + **kwargs, + "stream_options": { + **(kwargs.get("stream_options") or {}), + "include_usage": True, + }, + } + try: + result = target(**kwargs) + except BaseException as error: + self._meter.commit(transaction, request_status=request_status_of(error)) + raise + snapshot = UsageSnapshot(self._provider, path) + if kwargs.get("stream"): + return ProviderStream(result, self._meter, transaction, snapshot) + try: + snapshot.result(result) + except Exception: + log.exception( + "Kaiten AI usage: provider usage extraction failed; settling without usage" + ) + self._meter.commit(transaction, usage=snapshot.usage) + return result + + return call + if any(method.startswith(path + ".") for method in self._methods): + return Provider(target, self._meter, self._provider, path=path, root=self._root) + return target + + def _reserve(self, path: str, kwargs: dict[str, Any]) -> str | None: + if self._meter.unsupported: + return None + model = kwargs.get("model") + if not isinstance(model, str) or not model: + raise ValueError("a metered provider call requires an explicit model") + request = AiRequest(self._provider, path, model, self._methods[path], kwargs) + return self._meter.reserve( + model=model, + request_type=request.request_type, + usage=self._meter.estimate(request), + ttl_seconds=ttl_from_timeout(kwargs.get("timeout", get(self._root, "timeout"))), + ) + + def __enter__(self) -> Provider: + self._client.__enter__() + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + traceback: TracebackType | None, + ) -> Any: + return self._client.__exit__(exc_type, exc, traceback) + + +class ProviderStream: + """Delegated stream with exactly one best-effort settlement on consumption/close.""" + + def __init__( + self, stream: Any, meter: Meter, transaction: str, snapshot: UsageSnapshot + ) -> None: + self._stream = stream + self._iterator = stream.__iter__() + self._meter = meter + self._transaction = transaction + self._snapshot = snapshot + self._settled = False + + def __getattr__(self, name: str) -> Any: + return getattr(self._stream, name) + + def __iter__(self) -> Iterator[Any]: + return self + + def __next__(self) -> Any: + try: + event = self._iterator.__next__() + except StopIteration: + self._settle("PROVIDER_ERROR" if self._snapshot.failed else "SUCCESS") + raise + except BaseException as error: + self._settle(request_status_of(error)) + raise + try: + self._snapshot.event(event) + except Exception: + log.exception("Kaiten AI usage: stream usage extraction failed; keeping known usage") + if self._snapshot.completed: + self._settle("PROVIDER_ERROR" if self._snapshot.failed else "SUCCESS") + return event + + def _settle(self, status: AiRequestStatus) -> None: + if not self._settled: + self._settled = True + self._meter.commit(self._transaction, request_status=status, usage=self._snapshot.usage) + + def close(self) -> None: + try: + self._stream.close() + finally: + status: AiRequestStatus = ( + "SUCCESS" + if self._snapshot.completed + else "CUSTOMER_ERROR" + if self._snapshot.received + else "NOT_SENT" + ) + self._settle(status) + + def __enter__(self) -> ProviderStream: + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + traceback: TracebackType | None, + ) -> None: + self.close() + + @property + def text_stream(self) -> Iterator[str]: + def texts() -> Iterator[str]: + for event in self: + kind = get(event, "type") + delta = get(event, "delta") + if kind == "text": + yield get(event, "text", "") + elif ( + self._snapshot.operation != "messages.stream" + and kind == "content_block_delta" + and get(delta, "type") == "text_delta" + ): + yield get(delta, "text", "") + + return texts() + + def until_done(self) -> None: + for _ in self: + pass + + def get_final_message(self) -> Any: + self.until_done() + return self._stream.get_final_message() + + def get_final_text(self) -> str: + message = self.get_final_message() + return "".join(get(block, "text", "") for block in get(message, "content", [])) + + +class ProviderStreamManager: + """Anthropic's stream factory is synchronous, opening the network on async entry.""" + + def __init__( + self, provider: Provider, target: Callable[..., Any], path: str, kwargs: dict[str, Any] + ) -> None: + self._provider = provider + self._target = target + self._path = path + self._kwargs = kwargs + self._manager: Any = None + self._stream: ProviderStream | None = None + + def __enter__(self) -> Any: + transaction = self._provider._reserve(self._path, self._kwargs) + try: + self._manager = self._target(**self._kwargs) + stream = self._manager.__enter__() + except BaseException as error: + if transaction is not None: + self._provider._meter.commit(transaction, request_status=request_status_of(error)) + raise + if transaction is None: + return stream + self._stream = ProviderStream( + stream, self._provider._meter, transaction, UsageSnapshot("anthropic", self._path) + ) + return self._stream + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + traceback: TracebackType | None, + ) -> Any: + try: + return self._manager.__exit__(exc_type, exc, traceback) + finally: + if self._stream is not None: + self._stream.close() diff --git a/src/kaitencloud/_version.py b/src/kaitencloud/_version.py index bfc8daa..d37edb2 100644 --- a/src/kaitencloud/_version.py +++ b/src/kaitencloud/_version.py @@ -1,4 +1,4 @@ # Copyright 2026 KAITEN INC # SPDX-License-Identifier: Apache-2.0 -__version__ = "1.0.0" +__version__ = "1.1.0" diff --git a/src/kaitencloud/anthropic.py b/src/kaitencloud/anthropic.py new file mode 100644 index 0000000..f6f4e7f --- /dev/null +++ b/src/kaitencloud/anthropic.py @@ -0,0 +1,39 @@ +# Copyright 2026 KAITEN INC +# SPDX-License-Identifier: Apache-2.0 + +"""Optional anthropic drop-in clients and existing-client delegation.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from ._ai import Estimator +from ._async.client import AsyncKaitenClient +from ._provider_clients import drop_in, wrap +from ._sync.client import KaitenClient + +if TYPE_CHECKING: + Anthropic: type[Any] + AsyncAnthropic: type[Any] + +__all__ = ["Anthropic", "AsyncAnthropic", "wrap_anthropic"] + + +def wrap_anthropic( + client: Any, + *, + kaiten: KaitenClient | AsyncKaitenClient, + instance: str, + pool: str, + estimator: Estimator | None = None, +) -> Any: + """Meter the allow-list on an existing client; unlisted methods stay provider-owned.""" + return wrap(client, kaiten, "anthropic", instance=instance, pool=pool, estimator=estimator) + + +def __getattr__(name: str) -> Any: + if name in ("Anthropic", "AsyncAnthropic"): + client_type = drop_in("anthropic", name, __name__) + globals()[name] = client_type + return client_type + raise AttributeError(name) diff --git a/src/kaitencloud/openai.py b/src/kaitencloud/openai.py new file mode 100644 index 0000000..3396b08 --- /dev/null +++ b/src/kaitencloud/openai.py @@ -0,0 +1,39 @@ +# Copyright 2026 KAITEN INC +# SPDX-License-Identifier: Apache-2.0 + +"""Optional openai drop-in clients and existing-client delegation.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from ._ai import Estimator +from ._async.client import AsyncKaitenClient +from ._provider_clients import drop_in, wrap +from ._sync.client import KaitenClient + +if TYPE_CHECKING: + OpenAI: type[Any] + AsyncOpenAI: type[Any] + +__all__ = ["AsyncOpenAI", "OpenAI", "wrap_openai"] + + +def wrap_openai( + client: Any, + *, + kaiten: KaitenClient | AsyncKaitenClient, + instance: str, + pool: str, + estimator: Estimator | None = None, +) -> Any: + """Meter the allow-list on an existing client; unlisted methods stay provider-owned.""" + return wrap(client, kaiten, "openai", instance=instance, pool=pool, estimator=estimator) + + +def __getattr__(name: str) -> Any: + if name in ("OpenAI", "AsyncOpenAI"): + client_type = drop_in("openai", name, __name__) + globals()[name] = client_type + return client_type + raise AttributeError(name) diff --git a/tests/test_provider_compatibility.py b/tests/test_provider_compatibility.py new file mode 100644 index 0000000..03b632e --- /dev/null +++ b/tests/test_provider_compatibility.py @@ -0,0 +1,258 @@ +# Copyright 2026 KAITEN INC +# SPDX-License-Identifier: Apache-2.0 + +"""Pinned optional provider SDKs, driven entirely by in-process mock transports.""" + +from __future__ import annotations + +import asyncio +import json +from collections.abc import Callable +from typing import Any + +import pytest +from ai_fakes import FakeKaiten + + +@pytest.mark.parametrize("asynchronous", [False, True]) +@pytest.mark.parametrize("provider", ["openai", "anthropic"]) +def test_real_drop_in_and_copy_use_mock_transports( + make_client: Callable[..., Any], + make_async_client: Callable[..., Any], + asynchronous: bool, + provider: str, +) -> None: + sdk = pytest.importorskip(provider) + httpx2 = pytest.importorskip("httpx2") + fake = FakeKaiten() + calls = [] + if provider == "openai": + payload = { + "id": "r", + "object": "chat.completion", + "created": 1, + "model": "m", + "choices": [], + "usage": {"prompt_tokens": 10, "completion_tokens": 2, "total_tokens": 12}, + } + else: + payload = { + "id": "r", + "type": "message", + "role": "assistant", + "model": "m", + "content": [], + "stop_reason": "end_turn", + "stop_sequence": None, + "usage": {"input_tokens": 10, "output_tokens": 2}, + } + + def handler(request: Any) -> Any: + calls.append(json.loads(request.content)) + return httpx2.Response(200, json=payload) + + import kaitencloud.anthropic as anthropic_module + import kaitencloud.openai as openai_module + + module = openai_module if provider == "openai" else anthropic_module + name = ("Async" if asynchronous else "") + ("OpenAI" if provider == "openai" else "Anthropic") + cls = getattr(module, name) + + async def scenario() -> None: + kaiten, _ = make_async_client(fake) + async with ( + kaiten, + cls( + api_key="synthetic", + kaiten=kaiten, + instance="i", + pool="p", + http_client=httpx2.AsyncClient(transport=httpx2.MockTransport(handler)), + ) as client, + ): + copied = client.with_options(timeout=2) + method = ( + copied.chat.completions.create if provider == "openai" else copied.messages.create + ) + await method(model="m", messages=[], max_tokens=3) + + if asynchronous: + asyncio.run(scenario()) + else: + kaiten, _ = make_client(fake) + with ( + kaiten, + cls( + api_key="synthetic", + kaiten=kaiten, + instance="i", + pool="p", + http_client=httpx2.Client(transport=httpx2.MockTransport(handler)), + ) as client, + ): + assert isinstance(client, getattr(sdk, name)) + copied = client.with_options(timeout=2) + method = ( + copied.chat.completions.create if provider == "openai" else copied.messages.create + ) + method(model="m", messages=[], max_tokens=3) + assert len(calls) == 1 + assert len(fake.reserves) == len(fake.commits) == 1 + assert fake.commits[0]["usage"] == {"inputTokens": 10, "outputTokens": 2} + + +@pytest.mark.parametrize("asynchronous", [False, True]) +def test_real_anthropic_text_stream_does_not_duplicate_text( + make_client: Callable[..., Any], + make_async_client: Callable[..., Any], + asynchronous: bool, +) -> None: + pytest.importorskip("anthropic") + httpx2 = pytest.importorskip("httpx2") + from kaitencloud.anthropic import Anthropic, AsyncAnthropic + + fake = FakeKaiten() + events = [ + { + "type": "message_start", + "message": { + "id": "r", + "type": "message", + "role": "assistant", + "model": "m", + "content": [], + "stop_reason": None, + "stop_sequence": None, + "usage": {"input_tokens": 10, "output_tokens": 1}, + }, + }, + {"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}}, + { + "type": "content_block_delta", + "index": 0, + "delta": {"type": "text_delta", "text": "hello"}, + }, + {"type": "content_block_stop", "index": 0}, + { + "type": "message_delta", + "delta": {"stop_reason": "end_turn", "stop_sequence": None}, + "usage": {"output_tokens": 2}, + }, + {"type": "message_stop"}, + ] + sse = "".join(f"event: {event['type']}\ndata: {json.dumps(event)}\n\n" for event in events) + + def handler(request: Any) -> Any: + return httpx2.Response(200, text=sse, headers={"content-type": "text/event-stream"}) + + async def scenario() -> str: + kaiten, _ = make_async_client(fake) + async with ( + kaiten, + AsyncAnthropic( + api_key="synthetic", + kaiten=kaiten, + instance="i", + pool="p", + http_client=httpx2.AsyncClient(transport=httpx2.MockTransport(handler)), + ) as client, + client.messages.stream(model="m", max_tokens=3, messages=[]) as stream, + ): + text = "".join([part async for part in stream.text_stream]) + assert (await stream.get_final_message()).usage.output_tokens == 2 + return text + + if asynchronous: + text = asyncio.run(scenario()) + else: + kaiten, _ = make_client(fake) + with ( + kaiten, + Anthropic( + api_key="synthetic", + kaiten=kaiten, + instance="i", + pool="p", + http_client=httpx2.Client(transport=httpx2.MockTransport(handler)), + ) as client, + client.messages.stream(model="m", max_tokens=3, messages=[]) as stream, + ): + text = "".join(stream.text_stream) + assert stream.get_final_message().usage.output_tokens == 2 + assert text == "hello" + assert len(fake.commits) == 1 + assert fake.commits[0]["usage"] == {"inputTokens": 10, "outputTokens": 2} + + +@pytest.mark.parametrize("asynchronous", [False, True]) +def test_real_openai_chat_stream_reports_final_usage( + make_client: Callable[..., Any], + make_async_client: Callable[..., Any], + asynchronous: bool, +) -> None: + pytest.importorskip("openai") + httpx2 = pytest.importorskip("httpx2") + from kaitencloud.openai import AsyncOpenAI, OpenAI + + fake = FakeKaiten() + chunks = [ + { + "id": "r", + "object": "chat.completion.chunk", + "created": 1, + "model": "m", + "choices": [{"index": 0, "delta": {"content": "hello"}, "finish_reason": None}], + "usage": None, + }, + { + "id": "r", + "object": "chat.completion.chunk", + "created": 1, + "model": "m", + "choices": [], + "usage": {"prompt_tokens": 10, "completion_tokens": 2, "total_tokens": 12}, + }, + ] + sse = "".join(f"data: {json.dumps(chunk)}\n\n" for chunk in chunks) + "data: [DONE]\n\n" + + def handler(request: Any) -> Any: + assert json.loads(request.content)["stream_options"]["include_usage"] is True + return httpx2.Response(200, text=sse, headers={"content-type": "text/event-stream"}) + + async def scenario() -> None: + kaiten, _ = make_async_client(fake) + async with ( + kaiten, + AsyncOpenAI( + api_key="synthetic", + kaiten=kaiten, + instance="i", + pool="p", + http_client=httpx2.AsyncClient(transport=httpx2.MockTransport(handler)), + ) as client, + ): + stream = await client.chat.completions.create(model="m", messages=[], stream=True) + async with stream: + received = [chunk async for chunk in stream] + assert received[0].choices[0].delta.content == "hello" + + if asynchronous: + asyncio.run(scenario()) + else: + kaiten, _ = make_client(fake) + with ( + kaiten, + OpenAI( + api_key="synthetic", + kaiten=kaiten, + instance="i", + pool="p", + http_client=httpx2.Client(transport=httpx2.MockTransport(handler)), + ) as client, + ): + stream = client.chat.completions.create(model="m", messages=[], stream=True) + with stream: + received = list(stream) + assert received[0].choices[0].delta.content == "hello" + assert len(fake.commits) == 1 + assert fake.commits[0]["usage"] == {"inputTokens": 10, "outputTokens": 2} diff --git a/tests/test_providers.py b/tests/test_providers.py new file mode 100644 index 0000000..0c2b2c9 --- /dev/null +++ b/tests/test_providers.py @@ -0,0 +1,433 @@ +# Copyright 2026 KAITEN INC +# SPDX-License-Identifier: Apache-2.0 + +"""Synthetic provider clients and streams: no optional SDK or network required.""" + +from __future__ import annotations + +import asyncio +import sys +from collections.abc import Callable +from types import SimpleNamespace +from typing import Any + +import pytest +from ai_fakes import FakeKaiten, problem, status + +from kaitencloud import InsufficientCreditsError +from kaitencloud.anthropic import wrap_anthropic +from kaitencloud.openai import wrap_openai + + +class Stream: + def __init__(self, events: list[Any]) -> None: + self.events = iter(events) + self.closed = False + + def __iter__(self) -> Stream: + return self + + def __next__(self) -> Any: + return next(self.events) + + def __aiter__(self) -> Stream: + return self + + async def __anext__(self) -> Any: + try: + return next(self.events) + except StopIteration: + raise StopAsyncIteration from None + + def close(self) -> None: + self.closed = True + + +class AsyncStream(Stream): + async def close(self) -> None: # type: ignore[override] + self.closed = True + + +class Manager: + def __init__(self, stream: Stream) -> None: + self.stream = stream + + def __enter__(self) -> Stream: + return self.stream + + def __exit__(self, *args: Any) -> None: + self.stream.close() + + async def __aenter__(self) -> Stream: + return self.stream + + async def __aexit__(self, *args: Any) -> None: + await self.stream.close() # type: ignore[func-returns-value] + + +class Provider: + def __init__(self, result: Any, asynchronous: bool = False) -> None: + self.calls: list[dict[str, Any]] = [] + self.result = result + self.timeout = 30 + self.unlisted = object() + + def call(**kwargs: Any) -> Any: + self.calls.append(kwargs) + if isinstance(self.result, BaseException): + raise self.result + return self.result + + async def async_call(**kwargs: Any) -> Any: + return call(**kwargs) + + method = async_call if asynchronous else call + resource = SimpleNamespace(create=method, unlisted=self.unlisted) + self.chat = SimpleNamespace(completions=resource) + self.responses = resource + self.embeddings = resource + self.images = SimpleNamespace(generate=method) + self.audio = SimpleNamespace(transcriptions=resource, speech=resource) + self.messages = SimpleNamespace( + create=method, stream=lambda **kwargs: Manager(call(**kwargs)) + ) + + def copy(self, **kwargs: Any) -> Provider: + return self + + with_options = copy + + +@pytest.mark.parametrize("asynchronous", [False, True]) +@pytest.mark.parametrize("provider_name", ["openai", "anthropic"]) +def test_provider_reserves_calls_and_commits_without_changing_the_result( + make_client: Callable[..., Any], + make_async_client: Callable[..., Any], + asynchronous: bool, + provider_name: str, +) -> None: + fake = FakeKaiten() + usage = ( + {"prompt_tokens": 12, "completion_tokens": 3} + if provider_name == "openai" + else {"input_tokens": 12, "output_tokens": 3} + ) + result = SimpleNamespace(usage=usage) + provider = Provider(result, asynchronous) + wrap = wrap_openai if provider_name == "openai" else wrap_anthropic + + async def scenario() -> Any: + client, _ = make_async_client(fake) + async with client: + wrapped = wrap(provider, kaiten=client, instance="i", pool="p") + assert wrapped.unlisted is provider.unlisted + method = ( + wrapped.chat.completions.create + if provider_name == "openai" + else wrapped.messages.create + ) + return await method(model="m", messages=[], max_tokens=5) + + if asynchronous: + answer = asyncio.run(scenario()) + else: + client, _ = make_client(fake) + with client: + wrapped = wrap(provider, kaiten=client, instance="i", pool="p") + assert wrapped.unlisted is provider.unlisted + method = ( + wrapped.chat.completions.create + if provider_name == "openai" + else wrapped.messages.create + ) + answer = method(model="m", messages=[], max_tokens=5) + assert answer is result + assert [phase for phase, _ in fake.phases] == ["reserve", "commit"] + assert fake.reserves[0]["ttlSeconds"] == 30 + assert fake.commits[0]["usage"] == {"inputTokens": 12, "outputTokens": 3} + + +def test_refusal_prevents_the_provider_and_unsupported_degrades_once( + make_client: Callable[..., Any], +) -> None: + for code, status_code in [("ThresholdExceeded", 409), ("InvalidValueType", 422)]: + fake = FakeKaiten() + fake.refuse("reserve", problem(status_code, code)) + client, _ = make_client(fake) + provider = Provider("reply") + with client: + wrapped = wrap_openai(provider, kaiten=client, instance="i", pool="p") + if status_code == 409: + with pytest.raises(InsufficientCreditsError): + wrapped.responses.create(model="m", input="x") + assert provider.calls == [] + else: + for _ in range(2): + assert wrapped.responses.create(model="m", input="x") == "reply" + assert len(fake.reports) == 1 + + +@pytest.mark.parametrize("asynchronous", [False, True]) +@pytest.mark.parametrize("abandoned", [False, True]) +@pytest.mark.parametrize("provider_name", ["openai", "anthropic"]) +def test_stream_completion_and_partial_close( + make_client: Callable[..., Any], + make_async_client: Callable[..., Any], + asynchronous: bool, + abandoned: bool, + provider_name: str, +) -> None: + fake = FakeKaiten() + if provider_name == "openai": + events = [ + {"choices": [{"delta": {"content": "x"}}]}, + {"choices": [], "usage": {"prompt_tokens": 20, "completion_tokens": 4}}, + ] + else: + events = [ + { + "type": "message_start", + "message": { + "usage": {"input_tokens": 20, "output_tokens": 1, "cache_read_input_tokens": 6} + }, + }, + {"type": "message_delta", "usage": {"output_tokens": 2}}, + {"type": "message_delta", "usage": {"output_tokens": 4}}, + {"type": "message_stop"}, + ] + raw = AsyncStream(events) if asynchronous else Stream(events) + provider = Provider(raw, asynchronous) + wrap = wrap_openai if provider_name == "openai" else wrap_anthropic + + async def scenario() -> None: + client, _ = make_async_client(fake) + async with client: + wrapped = wrap(provider, kaiten=client, instance="i", pool="p") + if provider_name == "anthropic": + async with wrapped.messages.stream(model="m", messages=[], max_tokens=5) as stream: + if abandoned: + await stream.__anext__() + else: + async for _ in stream: + pass + else: + stream = await wrapped.chat.completions.create( + model="m", messages=[], stream=True, stream_options={"other": True} + ) + if abandoned: + await stream.__anext__() + else: + async for _ in stream: + pass + await stream.close() + await stream.close() + + if asynchronous: + asyncio.run(scenario()) + else: + client, _ = make_client(fake) + with client: + wrapped = wrap(provider, kaiten=client, instance="i", pool="p") + if provider_name == "anthropic": + with wrapped.messages.stream(model="m", messages=[], max_tokens=5) as stream: + next(stream) if abandoned else list(stream) + else: + stream = wrapped.chat.completions.create( + model="m", messages=[], stream=True, stream_options={"other": True} + ) + next(stream) if abandoned else list(stream) + stream.close() + stream.close() + assert raw.closed + assert len(fake.commits) == 1 + assert fake.commits[0]["requestStatus"] == ("CUSTOMER_ERROR" if abandoned else "SUCCESS") + if not abandoned: + assert fake.commits[0]["usage"]["outputTokens"] == 4 + if provider_name == "anthropic": + assert fake.commits[0]["usage"]["cacheReadInputTokens"] == 6 + else: + assert provider.calls[0]["stream_options"] == {"other": True, "include_usage": True} + + +def test_commit_failure_does_not_replace_success_and_copy_stays_wrapped( + make_client: Callable[..., Any], +) -> None: + fake = FakeKaiten() + fake.refuse("commit", *[status(503)] * 4) + client, _ = make_client(fake) + provider = Provider("reply") + with client: + wrapped = wrap_openai(provider, kaiten=client, instance="i", pool="p") + assert wrapped.with_options(timeout=1).responses.create(model="m") == "reply" + assert len(fake.commits) == 4 + + +def test_drop_in_is_a_provider_subclass_and_uses_the_same_loop( + make_client: Callable[..., Any], monkeypatch: pytest.MonkeyPatch +) -> None: + from kaitencloud._provider_clients import drop_in + + class OpenAI(Provider): + def __init__(self, **kwargs: Any) -> None: + super().__init__(SimpleNamespace(usage={"prompt_tokens": 1, "completion_tokens": 2})) + + monkeypatch.setitem(sys.modules, "openai", SimpleNamespace(OpenAI=OpenAI)) + client_type = drop_in("openai", "OpenAI", "kaitencloud.openai") + fake = FakeKaiten() + client, _ = make_client(fake) + with client: + provider = client_type(kaiten=client, instance="i", pool="p") + assert isinstance(provider, OpenAI) + provider.responses.create(model="m") + assert len(fake.commits) == 1 + + +@pytest.mark.parametrize("asynchronous", [False, True]) +@pytest.mark.parametrize("failed", [False, True]) +def test_responses_terminal_event_settles_once( + make_client: Callable[..., Any], + make_async_client: Callable[..., Any], + asynchronous: bool, + failed: bool, +) -> None: + fake = FakeKaiten() + events = [ + {"type": "response.output_text.delta", "delta": "x"}, + { + "type": "response.failed" if failed else "response.completed", + "response": { + "usage": { + "input_tokens": 10, + "input_tokens_details": {"cached_tokens": 3}, + "output_tokens": 2, + } + }, + }, + ] + raw = AsyncStream(events) if asynchronous else Stream(events) + provider = Provider(raw, asynchronous) + + async def scenario() -> None: + client, _ = make_async_client(fake) + async with client: + wrapped = wrap_openai(provider, kaiten=client, instance="i", pool="p") + stream = await wrapped.responses.create(model="m", stream=True) + async with stream: + async for _ in stream: + pass + + if asynchronous: + asyncio.run(scenario()) + else: + client, _ = make_client(fake) + with client: + wrapped = wrap_openai(provider, kaiten=client, instance="i", pool="p") + with wrapped.responses.create(model="m", stream=True) as stream: + list(stream) + assert "stream_options" not in provider.calls[0] + assert len(fake.commits) == 1 + assert fake.commits[0]["requestStatus"] == ("PROVIDER_ERROR" if failed else "SUCCESS") + assert fake.commits[0]["usage"] == { + "inputTokens": 7, + "cacheReadInputTokens": 3, + "outputTokens": 2, + } + + +@pytest.mark.parametrize( + "operation", + ["embeddings.create", "images.generate", "audio.transcriptions.create", "audio.speech.create"], +) +def test_remaining_openai_allow_list(make_client: Callable[..., Any], operation: str) -> None: + fake = FakeKaiten() + if operation == "images.generate": + result = SimpleNamespace(data=[object(), object()], usage=None) + elif operation == "audio.speech.create": + result = b"synthetic audio" + elif operation == "audio.transcriptions.create": + result = SimpleNamespace(usage={"type": "duration", "seconds": 2.5}) + else: + result = SimpleNamespace(usage={"prompt_tokens": 3}) + client, _ = make_client(fake) + with client: + target = wrap_openai(Provider(result), kaiten=client, instance="i", pool="p") + for part in operation.split("."): + target = getattr(target, part) + assert target(model="m") is result + assert len(fake.commits) == 1 + if operation == "images.generate": + assert fake.commits[0]["usage"] == {"units": "2", "unitType": "image"} + elif operation == "audio.speech.create": + assert "usage" not in fake.commits[0] + elif operation == "audio.transcriptions.create": + assert fake.commits[0]["usage"] == {"durationMs": 2500} + + +@pytest.mark.parametrize("asynchronous", [False, True]) +def test_unread_stream_releases_on_close( + make_client: Callable[..., Any], make_async_client: Callable[..., Any], asynchronous: bool +) -> None: + fake = FakeKaiten() + provider = Provider(AsyncStream([]) if asynchronous else Stream([]), asynchronous) + + async def scenario() -> None: + client, _ = make_async_client(fake) + async with client: + stream = await wrap_openai( + provider, kaiten=client, instance="i", pool="p" + ).responses.create(model="m", stream=True) + await stream.close() + + if asynchronous: + asyncio.run(scenario()) + else: + client, _ = make_client(fake) + with client: + stream = wrap_openai(provider, kaiten=client, instance="i", pool="p").responses.create( + model="m", stream=True + ) + stream.close() + assert fake.commits[0]["requestStatus"] == "NOT_SENT" + + +@pytest.mark.parametrize("asynchronous", [False, True]) +def test_stream_read_failure_commits_provider_error_with_partial_usage( + make_client: Callable[..., Any], + make_async_client: Callable[..., Any], + asynchronous: bool, +) -> None: + import httpx + + class BrokenStream(Stream): + def __next__(self) -> Any: + raise httpx.ReadError("synthetic reset after send") + + async def __anext__(self) -> Any: + raise httpx.ReadError("synthetic reset after send") + + fake = FakeKaiten() + raw = BrokenStream([]) + provider = Provider(raw, asynchronous) + + async def scenario() -> None: + client, _ = make_async_client(fake) + async with client: + stream = await wrap_openai( + provider, kaiten=client, instance="i", pool="p" + ).responses.create(model="m", stream=True) + with pytest.raises(httpx.ReadError): + await stream.__anext__() + + if asynchronous: + asyncio.run(scenario()) + else: + client, _ = make_client(fake) + with client: + stream = wrap_openai(provider, kaiten=client, instance="i", pool="p").responses.create( + model="m", stream=True + ) + with pytest.raises(httpx.ReadError): + next(stream) + stream.close() + assert len(fake.commits) == 1 + assert fake.commits[0]["requestStatus"] == "PROVIDER_ERROR" diff --git a/uv.lock b/uv.lock index a7844f2..5c275ba 100644 --- a/uv.lock +++ b/uv.lock @@ -3,7 +3,8 @@ revision = 3 requires-python = ">=3.10" resolution-markers = [ "python_full_version >= '3.15'", - "python_full_version >= '3.11' and python_full_version < '3.15'", + "python_full_version >= '3.12' and python_full_version < '3.15' and sys_platform == 'emscripten'", + "(python_full_version >= '3.11' and python_full_version < '3.15' and sys_platform != 'emscripten') or (python_full_version == '3.11.*' and sys_platform == 'emscripten')", "python_full_version < '3.11'", ] @@ -16,6 +17,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" }, ] +[[package]] +name = "anthropic" +version = "1.15.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "docstring-parser" }, + { name = "httpx2" }, + { name = "jiter" }, + { name = "opentelemetry-api" }, + { name = "pydantic" }, + { name = "sniffio" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/10/37/5a2a4316df8babd28f096477a707cd75f240b4264c97fb128c32375105d6/anthropic-1.15.0.tar.gz", hash = "sha256:90caa9df83e2fc8bddb4ddeb83b7191bce255f635c03eae055deec521741df08", size = 1726101, upload-time = "2026-10-15T19:45:34.635Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/65/54437e7edea49a3be9ef56f6b5965999bfdc52b9dadc9b448124ca51a594/anthropic-1.15.0-py3-none-any.whl", hash = "sha256:10189a7993c5c71a0a2d4bb80020ca6a2fd27679f51e627405da37251d15ff49", size = 2047755, upload-time = "2026-10-15T19:45:32.951Z" }, +] + [[package]] name = "anyio" version = "4.15.1" @@ -417,12 +437,21 @@ toml = [ { name = "tomli", marker = "python_full_version <= '3.11'" }, ] +[[package]] +name = "docstring-parser" +version = "0.18.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/4d/f332313098c1de1b2d2ff91cf2674415cc7cddab2ca1b01ae29774bd5fdf/docstring_parser-0.18.0.tar.gz", hash = "sha256:292510982205c12b1248696f44959db3cdd1740237a968ea1e2e7a900eeb2015", size = 29341, upload-time = "2026-04-14T04:09:19.867Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/5f/ed01f9a3cdffbd5a008556fc7b2a08ddb1cc6ace7effa7340604b1d16699/docstring_parser-0.18.0-py3-none-any.whl", hash = "sha256:b3fcbed555c47d8479be0796ef7e19c2670d428d72e96da63f3a40122860374b", size = 22484, upload-time = "2026-04-14T04:09:18.638Z" }, +] + [[package]] name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ @@ -451,6 +480,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, ] +[[package]] +name = "httpcore2" +version = "2.14.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "h11" }, + { name = "truststore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/30/ff/b53af1b49bb2cb5fa97c92215614b173ca5c1a99ded80e2d52f15a94fa6d/httpcore2-2.14.0.tar.gz", hash = "sha256:4f57a1a8249fe6af589d935cfd456f1d13bbdcb0f0fc72af9142368fc94811d7", size = 69096, upload-time = "2026-10-18T16:29:21.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/06/a8d6a8f90d11b76d9086242b6bb0ee4b503473bfc8be34c2d5b067275c22/httpcore2-2.14.0-py3-none-any.whl", hash = "sha256:4c4f64f4b544afeb778c42c0ffb5941b29e671157e90ed3f6dc787c4bca90cd1", size = 84291, upload-time = "2026-10-18T16:29:19.743Z" }, +] + [[package]] name = "httpx" version = "0.28.1" @@ -466,6 +508,32 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, ] +[[package]] +name = "httpx2" +version = "2.14.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio", marker = "sys_platform != 'emscripten'" }, + { name = "httpcore2", marker = "sys_platform != 'emscripten'" }, + { name = "httpx2-jsfetch", marker = "python_full_version >= '3.12' and sys_platform == 'emscripten'" }, + { name = "idna" }, + { name = "truststore", marker = "sys_platform != 'emscripten'" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e8/67/58353f1848b4ba568e1ec22ea1e0d8e039f0bca5fde7e2d1f07739fd83e7/httpx2-2.14.0.tar.gz", hash = "sha256:47ad61a3c40282cd8d0dc96311531f00f5bfc71a066c6fe3b36d8f63fb9e4f6e", size = 102036, upload-time = "2026-10-18T16:29:22.627Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/5a/469616aa8c2cbb0bb9dd67a3c18d07c01ab604cc0b236ee1c47d260c7f6e/httpx2-2.14.0-py3-none-any.whl", hash = "sha256:9d20c13a10bae1773a0be3b0f1e5beeb4bdd549bc3a2ba18efa32897c65403e4", size = 96901, upload-time = "2026-10-18T16:29:20.819Z" }, +] + +[[package]] +name = "httpx2-jsfetch" +version = "1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/c4/0e5636363151a2a1795e0a77617168b9ca438e1748ec05fc9b5687f93d64/httpx2_jsfetch-1.0.tar.gz", hash = "sha256:70a0e3eabfef7cce5ad9c629f7d01ca05e418f586646f4ddf14782e4c1454c60", size = 6872, upload-time = "2026-08-07T00:13:07.492Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/43/832f631d32e4f1211caa2ba368317739fe71f0b8530e4c9d15dc454bac2a/httpx2_jsfetch-1.0-py3-none-any.whl", hash = "sha256:cb916b707601e69a07721aabc8f3f6659be3a6893bc1ff5c6f9e02241df2da32", size = 6382, upload-time = "2026-08-07T00:13:06.567Z" }, +] + [[package]] name = "idna" version = "3.19" @@ -484,6 +552,132 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] +[[package]] +name = "jiter" +version = "0.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9c/1f/8176d92e001f86505424b41664032ae26a882bc9ca41a32c803f373f9195/jiter-0.17.0.tar.gz", hash = "sha256:03e432f226a453851079fb84cd17c6da9991eab723e28d716f14ae3d906e0c12", size = 229037, upload-time = "2026-09-12T15:14:14.253Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/fb/0b68a8666a203ab349334e502285153ac97e84a1a3e2f80de8385ed0899c/jiter-0.17.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:ed1a24005daac667d577402d75a2922f9775a165b146b883ff1ad3602d8be689", size = 290968, upload-time = "2026-09-12T15:11:10.44Z" }, + { url = "https://files.pythonhosted.org/packages/ca/e9/5993079500530586655c6f2823e2926b3f5ceddd49918c5a86757eadba3a/jiter-0.17.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:b847b18d066c46b3b7ae49d6c94a7634c5e4a8983146ee25562a092000f5e3ad", size = 324072, upload-time = "2026-09-12T15:11:12.03Z" }, + { url = "https://files.pythonhosted.org/packages/2a/56/6f5dbffaa5b0e647386e3c3376aa150987538f1b13f6da1a9582787957af/jiter-0.17.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7b68d3495d95da120651a5628c7ebadee84ed001a1b76e6afc325c42482f15b5", size = 344891, upload-time = "2026-09-12T15:11:13.387Z" }, + { url = "https://files.pythonhosted.org/packages/9a/9d/8dd5719add6c77be95b23c129682a1484c5339ffd66736d66bfa6831a2f3/jiter-0.17.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3c1a5336c04a41b1f1cf9572e294aec27cc569767ff73de7bf87a91f0bea7cb9", size = 328213, upload-time = "2026-09-12T15:11:14.655Z" }, + { url = "https://files.pythonhosted.org/packages/6e/e5/82f885863afaa79efac066efdb7b70bd242ef83d3e548453d61b3737e54a/jiter-0.17.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b75f85660108965a94be77911a25a253429307294d9415b3c597118977a614de", size = 343033, upload-time = "2026-09-12T15:11:16.046Z" }, + { url = "https://files.pythonhosted.org/packages/a0/41/ee3cd7db1704ec9c274ae549803ef1369c801490f7f6d48d0aa3b9ac1ae0/jiter-0.17.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:32aaaa764604496610a3ad2d98503ae88ccb2fbe769e892ff4533e778e85f708", size = 351701, upload-time = "2026-09-12T15:11:19.188Z" }, + { url = "https://files.pythonhosted.org/packages/93/2c/af9ca503b38683a82b6732352a861515f28e2d46fe445aea57e07fc86e06/jiter-0.17.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:826871c42cebaae22f0a2b5673a4a1a75c851bb2d13b3c17764a630a6b298984", size = 348754, upload-time = "2026-09-12T15:11:20.486Z" }, + { url = "https://files.pythonhosted.org/packages/d0/ba/692b071b194270b019b7d1790adb8658193379b951029e5ad15a14a2809d/jiter-0.17.0-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:00b5a98df3e3a3e8cf7b619f4ac2f8bf975bbf3d95d02c5d17b8dbfe5c8b8245", size = 329263, upload-time = "2026-09-12T15:11:21.701Z" }, + { url = "https://files.pythonhosted.org/packages/f1/8f/35a22beb6b9eb025b7e2f88a38ca3b9c87017aac237feec2583eabe243ad/jiter-0.17.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6af5b74073bd25bae695e6d00919f6a9be7ed5a9f8836d981eb1ffe84139e6fb", size = 337572, upload-time = "2026-09-12T15:11:23.145Z" }, + { url = "https://files.pythonhosted.org/packages/7a/0f/4341ceb3ce08199b4e8a798acc0bb0c79b3e861b6cecdb9d3b5138965c5d/jiter-0.17.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:16dd0c1baf098ae70b8f3616574eb3fedf34e26670b89e16a7e67561f737ed2d", size = 485174, upload-time = "2026-09-12T15:11:24.394Z" }, + { url = "https://files.pythonhosted.org/packages/a2/6d/92fa138ac82db9001b68563df204c32f2203bee84301df083260039ca23b/jiter-0.17.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:545c36a0f3b2238c242cc9785439d3242a871b7bc39fe3f441bcaa07bf3aa83e", size = 522115, upload-time = "2026-09-12T15:11:25.645Z" }, + { url = "https://files.pythonhosted.org/packages/52/92/9b11557ddf898e84c253d1c42cc1bddd8ad1a754b46b01ae35a6a253526f/jiter-0.17.0-cp310-cp310-win32.whl", hash = "sha256:155be7355bdb7ca76ab0961be8982c225f964a5c073a83984183f22391cc29fc", size = 188163, upload-time = "2026-09-12T15:11:26.793Z" }, + { url = "https://files.pythonhosted.org/packages/a5/f3/b4381cc962fed7d505913d5920e9f51f1fd5cf340a563134e8cadf3604d2/jiter-0.17.0-cp310-cp310-win_amd64.whl", hash = "sha256:37150a9e02e869475854fa20b7d0d5e26d18d0f8bc17293999973ff27e99ae7a", size = 231679, upload-time = "2026-09-12T15:11:28.911Z" }, + { url = "https://files.pythonhosted.org/packages/a1/50/17afdaffcc8af4bf4fddf2b6c26d066553aa2221983f2affcde435fc2532/jiter-0.17.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:cfafd7be8b16ceadd298db542cead37cddc211c4c49e04ad2596924df18625b1", size = 290699, upload-time = "2026-09-12T15:11:30.085Z" }, + { url = "https://files.pythonhosted.org/packages/c9/e4/c185d32d5b3657ad84da26c84a9eb15f00aa1b39d6882fcc0052dba2d7c2/jiter-0.17.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8adca2e793288e5f1bb29279bb439d0d3cfbb50eddca7e7e6ffd42ff4f482406", size = 324246, upload-time = "2026-09-12T15:11:31.3Z" }, + { url = "https://files.pythonhosted.org/packages/24/7a/8b8903bfe91a90a8fa1ec9b45d9fda5b6287a386693b69d720a882d73f3c/jiter-0.17.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:30c692d567ba206c7cca38c9d1d0ccc70c9786290173c184d871ca12e9981ed7", size = 343275, upload-time = "2026-09-12T15:11:32.758Z" }, + { url = "https://files.pythonhosted.org/packages/a2/5d/6821fae2abc71a3c3a84bef8598d31fc4f27d9edfb55bd8f6c08afb8ef93/jiter-0.17.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:81c83c0abe614446a283d994d2c07c4f58632dea2cdf66ba9e2921bb8ccd593e", size = 328034, upload-time = "2026-09-12T15:11:33.9Z" }, + { url = "https://files.pythonhosted.org/packages/f3/51/8e7a963b1c2dfdc01d6228b004f50a2a3d7c46f0549d7b096a5d15ef81d5/jiter-0.17.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:073dc68c1a700c8fc480e877864a6b6ffc887533e261f4380c08c16bf09d057a", size = 342457, upload-time = "2026-09-12T15:11:36.414Z" }, + { url = "https://files.pythonhosted.org/packages/73/27/8b2a267e3bda45d9298331cacfe3521e761f0a2b05ad10a23c6548d08358/jiter-0.17.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:492f37230bbf9581ab2c17bcda862c249afb9ae2e3ab2dd6db59943bc4cc3153", size = 351271, upload-time = "2026-09-12T15:11:37.692Z" }, + { url = "https://files.pythonhosted.org/packages/4a/8f/5c74e5e142a6736833d7a991ab04d5c0738038dc44db05af0bb3cd2559e8/jiter-0.17.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5888fe5abc1ca2fa834a3e1b4c7ef0dcece286a7d7e95a609ef0934b777b9fc9", size = 346052, upload-time = "2026-09-12T15:11:39.722Z" }, + { url = "https://files.pythonhosted.org/packages/98/9c/f54920f06d1696e80b1be841d56412871c6856b1b1e3b541b1ed35346554/jiter-0.17.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:84ac78df457e1ee3f7e733bd114823302ae8c5ad5542d7e6647d92ffaa090a04", size = 328915, upload-time = "2026-09-12T15:11:41.065Z" }, + { url = "https://files.pythonhosted.org/packages/21/53/080f126863bceb055db9f1fd5431485eb493b35545a3c35e961ac18cd924/jiter-0.17.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7573e80232c5bcf80c24c038cf7e53a463f5c3b1dd1dd4109d66304f4dccc233", size = 337240, upload-time = "2026-09-12T15:11:42.356Z" }, + { url = "https://files.pythonhosted.org/packages/f0/76/3ab742823a0e0e70e143c6c90a482d9d90396ac2445e7b9483eb7245d3b5/jiter-0.17.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:11902505d401691720f5785c15b02204248526edee11b635cd6c40cd52b81599", size = 484917, upload-time = "2026-09-12T15:11:43.549Z" }, + { url = "https://files.pythonhosted.org/packages/14/e0/8ca71bc8b9cc9ed96c9da565863e00f3bb875a8fb82abcf03e975e067902/jiter-0.17.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:64846211a2debe7c071d2146d2283d2b0c1c93dc8fd5fb7794faac2ca6061b5c", size = 521868, upload-time = "2026-09-12T15:11:44.704Z" }, + { url = "https://files.pythonhosted.org/packages/c1/02/81f8719dcedb75713082a2048405376c81f6546b75af8167f3bba01a1ed0/jiter-0.17.0-cp311-cp311-win32.whl", hash = "sha256:c19b9357309b8cc6de8a48fca8e44a8c9c2feaaa2f5896d037fa505d48fcab80", size = 187844, upload-time = "2026-09-12T15:11:45.874Z" }, + { url = "https://files.pythonhosted.org/packages/3e/8c/59693f348488f01ed12d862e99ab8da14961152d3e9c39b9b1ef363f3572/jiter-0.17.0-cp311-cp311-win_amd64.whl", hash = "sha256:e654b6b04e39c9cb19cb8b04c6ddf1f2db07751fa14156413969fd78bad0e5cb", size = 231402, upload-time = "2026-09-12T15:11:47.083Z" }, + { url = "https://files.pythonhosted.org/packages/fe/89/fb35e286463cb9f01edc2c4e47df6e5477ee36bca5096414e9ea87985588/jiter-0.17.0-cp311-cp311-win_arm64.whl", hash = "sha256:3ad556afc289f15d2b181b941982d01f06190863c07440185b9f354e1bd2def3", size = 187006, upload-time = "2026-09-12T15:11:48.245Z" }, + { url = "https://files.pythonhosted.org/packages/aa/f8/07bd8c3a23f7a8a6875e6a820bbffe1483a18f18f9398a91b5495123176e/jiter-0.17.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:ebf918dfd6a74adc1b9ad71f63c4ab00902fcd3b7fd39f2e24d871db8d713b91", size = 291633, upload-time = "2026-09-12T15:11:49.431Z" }, + { url = "https://files.pythonhosted.org/packages/0e/5e/0de4c6f84ffefa6809ffc2d550b9a314365acf7e7ec9b6c7375d49047900/jiter-0.17.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:61aed66ee042b3b49ef85fdf75714234d055d89d8496ac1c6e47f89e7a30d5e4", size = 321695, upload-time = "2026-09-12T15:11:52.727Z" }, + { url = "https://files.pythonhosted.org/packages/20/ac/befe2e82065bee37a0252081666ed2f48c1ac5f5c6c318c2de8168ba393d/jiter-0.17.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:76eb4a5c20e86f9f848286f167024890f2862258a965d254774deb7fc1545ca1", size = 341967, upload-time = "2026-09-12T15:11:54.231Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cd/9797c1e529746750ae589da7c1a8c24373f00d88e11a989f9e5eb1959079/jiter-0.17.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bcc064f99183a9cbe7f26ed648c352031a74145cd61ed75d34632c73eb46a5a8", size = 326546, upload-time = "2026-09-12T15:11:55.41Z" }, + { url = "https://files.pythonhosted.org/packages/d9/fd/e6914c38d6347bab4ebff2b1f0c0f191db276e7a1d5c376176757da42fe3/jiter-0.17.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:73b64e69c4150748e020356d958af94bec33c70a0a93d665cfa8f6d580fe1a63", size = 340995, upload-time = "2026-09-12T15:11:58.211Z" }, + { url = "https://files.pythonhosted.org/packages/9d/7d/611b3abf6f88945b5474da5cdc6d1a185e805ac9bf446bb7766dcda6ea87/jiter-0.17.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f0bc7f684b65bcda9c20434267577db71bf9905ceddd32b60d1d93278d8c8d3a", size = 352188, upload-time = "2026-09-12T15:11:59.414Z" }, + { url = "https://files.pythonhosted.org/packages/52/f8/b6e513ecbdf3b3cebe587c2279281ecf775b729a58cf4cc7bdf898ded029/jiter-0.17.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c21265b251d99bbb40080d178a8953e35601d3a1564e05c4de4c0d2ca616797", size = 345025, upload-time = "2026-09-12T15:12:00.697Z" }, + { url = "https://files.pythonhosted.org/packages/28/a8/fe26d06c5a6c5a4cfe703c5154c8a140da1305671eb3681aba9422d4f393/jiter-0.17.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:f3d7f7b34114f7ddc6d72a8e882d49de636b35d9fd12b4d420d3c5729f6c9812", size = 329180, upload-time = "2026-09-12T15:12:01.831Z" }, + { url = "https://files.pythonhosted.org/packages/e1/58/e6d66a26af40a20e62486feb7e222fd50f6e7aaa4f107abd89675dcc835b/jiter-0.17.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5078ab00664307fab2019b522a93aeb191122789f085daf5fd9e362154021d4a", size = 335805, upload-time = "2026-09-12T15:12:03.056Z" }, + { url = "https://files.pythonhosted.org/packages/ef/3e/96520aa2fef5ef831d95483a902140bfab83dcac9eaa74f7df61b5e50a1b/jiter-0.17.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:470e1b1e4c42f1ead2189166a299691871a2df5056c976e7fb96feafaf5f9d44", size = 484121, upload-time = "2026-09-12T15:12:04.414Z" }, + { url = "https://files.pythonhosted.org/packages/6a/8f/5d9d92fe538bf36ff481a2278c48147e59c1cf8eb2f7be665260665febe5/jiter-0.17.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:6eb6aedeb7352b8f3b6af9cbd67983840165c00428e63f1b420a85885128ea31", size = 521310, upload-time = "2026-09-12T15:12:05.612Z" }, + { url = "https://files.pythonhosted.org/packages/50/06/a09f979b22e652afbc3de66c709b2ba92edcef555f7535ab937c86b4f21a/jiter-0.17.0-cp312-cp312-win32.whl", hash = "sha256:362bb47423886d45a9f705d2d9d4008c6eedd4e41eb1bab4e96fb6daa06b33fd", size = 185029, upload-time = "2026-09-12T15:12:06.994Z" }, + { url = "https://files.pythonhosted.org/packages/6c/d9/98265a005b2473ec2be5a84e2b64c2f65382c673879f1574845cd4bcd77c/jiter-0.17.0-cp312-cp312-win_amd64.whl", hash = "sha256:9bd3caac219df476dd0cc3fe01d2f1581ed588906feac767abd9614c1c12f8b3", size = 227381, upload-time = "2026-09-12T15:12:08.823Z" }, + { url = "https://files.pythonhosted.org/packages/a8/11/2e05bf5a56e57a543ebb8f585074adf09383e99d7b062dac92eab1f4d57f/jiter-0.17.0-cp312-cp312-win_arm64.whl", hash = "sha256:36ee6e69027396664e59995b9a635a947a5304ee9837279584a0bb8145c8f6b8", size = 183610, upload-time = "2026-09-12T15:12:10.374Z" }, + { url = "https://files.pythonhosted.org/packages/40/eb/2c4a8075ed5ea02b56911e9375d4c8d7784572ff4af32e5a99ae0d071044/jiter-0.17.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1b18434638228c0c184281609bf3d9459026a0f1ea48fb76c205e3ef72069caa", size = 290991, upload-time = "2026-09-12T15:12:11.641Z" }, + { url = "https://files.pythonhosted.org/packages/ca/b1/34bfa29599d420423baac6ff7cada6674fe63d5a7a2ccb3900b904678783/jiter-0.17.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ec89771f4272b989487a6364e519db6bbaba323e8bbf949ac89a45ea9c18b7a3", size = 321425, upload-time = "2026-09-12T15:12:13.855Z" }, + { url = "https://files.pythonhosted.org/packages/11/71/a5ac64a62a04aebd556afadab14a6b730001e16df87266ded943a100a1d9/jiter-0.17.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e3f052c671d5f425cca5ea5901cf11a831369fba4a55a3862cab93c323b4c3b", size = 343138, upload-time = "2026-09-12T15:12:15.046Z" }, + { url = "https://files.pythonhosted.org/packages/01/dd/f761e320ea473314cb68612bc6a435393464dbd198051399b36848b4ebf3/jiter-0.17.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:785a216bbaf8f15fc974e964ced7322cd3d774bb0e86949edd78c6bffd6ba35b", size = 325805, upload-time = "2026-09-12T15:12:16.506Z" }, + { url = "https://files.pythonhosted.org/packages/19/1a/27d8e40f0fb29bbc7a5adf30907144396a115dbe93d5d8976c054a6dfe96/jiter-0.17.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d85c558c9f8532bba287a990ac63767c7daf756f0d8c030219f62499b1fa228a", size = 340230, upload-time = "2026-09-12T15:12:17.682Z" }, + { url = "https://files.pythonhosted.org/packages/ac/c0/30bcde78a28155461f965d16b7aca4ffca6d17494d905f7a0bb072e6c64e/jiter-0.17.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5c23849235d2142ce444b2b8c6eceee9f82f4cc0bd5c9081602e4155c6197807", size = 351343, upload-time = "2026-09-12T15:12:19.337Z" }, + { url = "https://files.pythonhosted.org/packages/27/17/91420b156315ae22732f5ee1a7b5725a030aab9dc8fd7dcdacfb4aa588d3/jiter-0.17.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58df29268a95e910f17db7ec9178eb7f15aa8619aaca3575275c4e6b3f4fe4c5", size = 344990, upload-time = "2026-09-12T15:12:20.705Z" }, + { url = "https://files.pythonhosted.org/packages/6d/a2/ae6d5672644cc11127970277c9aeb0fa6fae376845587f5b0a8e8828167c/jiter-0.17.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:a277f97eba7d66b1ee27eb5dab5b774ff46a10c78d89a1d3dcce04ce1357c8ca", size = 328624, upload-time = "2026-09-12T15:12:23.859Z" }, + { url = "https://files.pythonhosted.org/packages/04/62/45cb1162f6aa586536e4a973fc339d72dc6b08cca030d70a838a307aa778/jiter-0.17.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe15ddf316f1f1f643347d3a474e74ce61880c79a11ec5dca53df20c071bd3e8", size = 334731, upload-time = "2026-09-12T15:12:25.229Z" }, + { url = "https://files.pythonhosted.org/packages/d9/5f/45c1574b644da7deda0b7591c349520dcf83ce45b24d7ca19922dab1fc27/jiter-0.17.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:02adebb7ce6413c44d40af9ad59d1c1cd79630ccdcb6f7bdd2d461e48c03d8f9", size = 483649, upload-time = "2026-09-12T15:12:27.557Z" }, + { url = "https://files.pythonhosted.org/packages/c1/d3/ebea1ecb5b241c519f192b30215c79a8e47f42f1621acbcd6f8830728416/jiter-0.17.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:55d0e0e613a3f9ad600cf436e0e2b8057d1b52bcf1d91b2d36ac53451231e6a8", size = 520758, upload-time = "2026-09-12T15:12:28.99Z" }, + { url = "https://files.pythonhosted.org/packages/64/e6/682b641ff0765ea9bdc349dbc7d223de5c8af8ec1abda0db3406992f92fe/jiter-0.17.0-cp313-cp313-win32.whl", hash = "sha256:2c45ad7c973ef33fe5114a953377b35a95240f4542c0724d9f781e47dc24bac7", size = 184334, upload-time = "2026-09-12T15:12:30.813Z" }, + { url = "https://files.pythonhosted.org/packages/b8/d2/9a49aac2b27af4cc5015e368c0cc3588491a532f717a668ffce1f1ac57da/jiter-0.17.0-cp313-cp313-win_amd64.whl", hash = "sha256:a3cebb1fe4a1abb00465f3f8a17e09112603e8b7c59e5c3adbcd9f7815a64acd", size = 226601, upload-time = "2026-09-12T15:12:32.096Z" }, + { url = "https://files.pythonhosted.org/packages/b4/ce/9a43e9f614608eafa78de22aedcff54cd21324467b5d442d5c9b00244145/jiter-0.17.0-cp313-cp313-win_arm64.whl", hash = "sha256:96b8b0c6dc5d78682f54a450785e075aa929cde768304cad363cd4efba5a82ac", size = 183103, upload-time = "2026-09-12T15:12:34.396Z" }, + { url = "https://files.pythonhosted.org/packages/01/9e/23065f8e2c7a4c372c1b6f6622e4cfab4dc786cb5150052b1527e6a6a840/jiter-0.17.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:00d783a779c5664e16dbad5e3a3c3a75e128b07dd5f4765159658d9210a50ca5", size = 292210, upload-time = "2026-09-12T15:12:35.613Z" }, + { url = "https://files.pythonhosted.org/packages/ea/81/67b58647560bc82a4490d722caa8561d7a86a9f45d4fa620b7e5fe282c7a/jiter-0.17.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0619d806e260ecf0c2a64521942c94af5d547c9ec99b55ae4f51b538b5576a76", size = 321512, upload-time = "2026-09-12T15:12:36.907Z" }, + { url = "https://files.pythonhosted.org/packages/c7/07/6658359a25f55927f7f8bf0e16465dee2ccd0b2a1a5208acc0df8972e074/jiter-0.17.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dc0288ce39190ee33fe6e4ec73161eed34e7e2da509b525546ca061778d62b64", size = 343897, upload-time = "2026-09-12T15:12:38.189Z" }, + { url = "https://files.pythonhosted.org/packages/46/04/5d50a9f0319cbdc37fd53c27f8c313d46afc34f1b048219ae6d8ea068da4/jiter-0.17.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5a52a430d04225ffde633e6840bf2381d34c019ff98526b5929755b9052fb199", size = 326519, upload-time = "2026-09-12T15:12:39.532Z" }, + { url = "https://files.pythonhosted.org/packages/bb/c7/d02517832b29eb8275fdd0f4ce0f17b80f58cc4c3ebecd4d9ace990d633d/jiter-0.17.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:37f33d327900bf2879613b3363fd48df97b4232d0c41f54bcf2e790c2fc40a71", size = 341369, upload-time = "2026-09-12T15:12:41.486Z" }, + { url = "https://files.pythonhosted.org/packages/3b/07/499b5f5603501cdd93a73a6a176dfad9c96555a3ae58ca9f8e3acba63dc9/jiter-0.17.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6cf564d43c4388149ca58ee571d0f5ccf875e20d1fd4662fd94cc0d1ea3b10ef", size = 352160, upload-time = "2026-09-12T15:12:42.721Z" }, + { url = "https://files.pythonhosted.org/packages/f5/75/b04013c7743269d4533ef4e746fc0ed678a143968dd7448658e3f51daad2/jiter-0.17.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:523c499235fb65add25d4bb01b1c4709ce695efdc7deb6c0a7bc515b5c44e0fb", size = 345018, upload-time = "2026-09-12T15:12:44.192Z" }, + { url = "https://files.pythonhosted.org/packages/1d/96/cbb6fd1e42a77c8412ec4643db95059b30cdfc635e387cc9193e098ce268/jiter-0.17.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:455e4ab35cb2a4a91a8404e08fd3c621bae433922e59bf1c494fe20a426b013b", size = 329244, upload-time = "2026-09-12T15:12:45.491Z" }, + { url = "https://files.pythonhosted.org/packages/15/67/d3be402f398566a379bf40ae65be5c3505b14d9e95e0802a597ddde7ddee/jiter-0.17.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6871973bfbd4408f7f1c632b30bbb5bbd9671c1bc8650af6823e24b7be13709b", size = 335693, upload-time = "2026-09-12T15:12:46.935Z" }, + { url = "https://files.pythonhosted.org/packages/7f/8d/98e2c4130b93d64f1d67c89060b928d04102549bf05e64451c9e6024f9ca/jiter-0.17.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:77f6aac0137309b31448c1bdcda4c6c77077664a6d018ece8d94019c68a5a5b9", size = 484329, upload-time = "2026-09-12T15:12:48.361Z" }, + { url = "https://files.pythonhosted.org/packages/78/5e/8da91e49f0fbca37c3489fb4cf3ad6676d4965f00ae5468bca3a2513737a/jiter-0.17.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:93946d89fa04d5ba64dd323a8dd8d901676cb8a3c81d99ae4f6c051a9b4c3f2f", size = 521358, upload-time = "2026-09-12T15:12:49.856Z" }, + { url = "https://files.pythonhosted.org/packages/be/21/5388684a5a38af3557cd9c2424b9827c71809cff24373c75ef9d0d3dfba9/jiter-0.17.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:70f19a2ca8429f91e82eeffb2f51cb87bc2d6e953b009b91a92d29c3a16ccb03", size = 110459, upload-time = "2026-09-12T15:12:51.747Z" }, + { url = "https://files.pythonhosted.org/packages/b1/ad/58b3a93525d2ffca7f54d9dee441381990082bd1172fbeb8d6a3f72a4dc3/jiter-0.17.0-cp314-cp314-win32.whl", hash = "sha256:71dbd74314c5df52a1bccf7b8bca46d14e943af7a2012e73b23f49977ef194c8", size = 185043, upload-time = "2026-09-12T15:12:54.477Z" }, + { url = "https://files.pythonhosted.org/packages/7a/4a/1aa520eb6c359b262c14ff995ca7283837208ddfb1202082ce9d73cf214d/jiter-0.17.0-cp314-cp314-win_amd64.whl", hash = "sha256:ac3c6ee3264d6f5c44c617f90bc7e8b9e1587e7d6708c9d8f811cb65582ee312", size = 227163, upload-time = "2026-09-12T15:12:55.931Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e4/5997f648794bd9b499491d0ff480b096cc9a9c65bdba29f57568e6aa1705/jiter-0.17.0-cp314-cp314-win_arm64.whl", hash = "sha256:6219adaf59711ba7063a52496e8ec6d3fa3e209d7827d83eee3b2abc780a1744", size = 183505, upload-time = "2026-09-12T15:12:58.196Z" }, + { url = "https://files.pythonhosted.org/packages/ac/4a/84a5ec271d09f7590b6073af5ee4abb44eab4ccace453b7e2c5ce45234ca/jiter-0.17.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:59bddbe6f9ffecc68d641e1e2d619ce64cf8a9e9eeb74e5c518f74fc87abf1b0", size = 321527, upload-time = "2026-09-12T15:12:59.394Z" }, + { url = "https://files.pythonhosted.org/packages/39/71/9e1fd0045f5920b4c36be35c3f0f0dfd123668684f8ad352619d7aa44183/jiter-0.17.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6cb41cd1432f1dc19a231cf70b54d42b2c9f05085155859263fce06fa4d41388", size = 340865, upload-time = "2026-09-12T15:13:00.756Z" }, + { url = "https://files.pythonhosted.org/packages/b7/2b/14627fd2bc377f3dd09491bcace6b90e34b4d7fea2f1f3295031ff91f528/jiter-0.17.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fd7790aa79c8b518e512ebcdfce9f11d8ef5f30efd43720c8a19a548b39fa489", size = 325412, upload-time = "2026-09-12T15:13:02.152Z" }, + { url = "https://files.pythonhosted.org/packages/4c/f3/8d5808f7bf0f456bde79e6393587183a0cee5f83d179fe1f7f1eff2ba067/jiter-0.17.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dbbfe4e3c21c8166980cddc5bee1a315df082454f007947dfb6fb73800768165", size = 340473, upload-time = "2026-09-12T15:13:03.485Z" }, + { url = "https://files.pythonhosted.org/packages/4f/da/1d8c7c6c4ae6b2423b94a81b6b907d37b28f87664e077427b531bf1b5313/jiter-0.17.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c286860abfe8b100cac1c02e225e5776eb9216edd71ba17cdb237da4af32bc9", size = 350757, upload-time = "2026-09-12T15:13:04.828Z" }, + { url = "https://files.pythonhosted.org/packages/eb/96/c1813dcca15c5a370145a448aaea7d1f83f6f0228a5f1130e79340ee385f/jiter-0.17.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f753eb70b1474a29e635e7542ff7312e6d6b951e0b25e8a2e8c34eeb1ddcd478", size = 345203, upload-time = "2026-09-12T15:13:06.131Z" }, + { url = "https://files.pythonhosted.org/packages/d7/f7/fc61cbcf2992d169ede13648fc3fd8e2d3171a3669dde43cd4db556549ac/jiter-0.17.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:eae86b1f027031e39db2e0e9c4842221edb7b8cd474d23f87a79b3bd4b651768", size = 328322, upload-time = "2026-09-12T15:13:07.392Z" }, + { url = "https://files.pythonhosted.org/packages/8f/88/46418a3abbdffb7dc41b314200360f24f75faaeb35573e81c92de322cce9/jiter-0.17.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5bf350452a43173e69e1fc74847c57a60e3d7515807287f29849baa2a85d8718", size = 336570, upload-time = "2026-09-12T15:13:08.666Z" }, + { url = "https://files.pythonhosted.org/packages/f0/28/b8a55b949be6306df8888e365a8df05441de8a7b11289f6957004302e41e/jiter-0.17.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:da139721f4b7cafdbff580a4f511ea24cb91f4909330c6b926a1ca53836c0a59", size = 482879, upload-time = "2026-09-12T15:13:10.037Z" }, + { url = "https://files.pythonhosted.org/packages/75/3b/21d0afa53ba0680962c39f3eb95ed2946f8793369ed44b0c82b490723081/jiter-0.17.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:8079849db9a1371bfd90bad088458a8fb836261879df2233cc9632464ecf64e1", size = 520406, upload-time = "2026-09-12T15:13:11.456Z" }, + { url = "https://files.pythonhosted.org/packages/ef/03/bcbaf8b6b9ea23c2c074411f8ecfbb02d820abac5d0cb8f4e280209174a2/jiter-0.17.0-cp314-cp314t-win32.whl", hash = "sha256:8f770b0c77e5fac482e1ba03ca1a7e18286bfb213d749932a00a7e4cd5de5e06", size = 184434, upload-time = "2026-09-12T15:13:13.037Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b5/5d6ce2c93ef6fe1241b37a9005547f9b6d58db1f07f39fe95807d4b98f51/jiter-0.17.0-cp314-cp314t-win_amd64.whl", hash = "sha256:c4289293e5278d9314b00f15c37f2120fa51d3d68565292e715524c750e775a9", size = 227392, upload-time = "2026-09-12T15:13:14.933Z" }, + { url = "https://files.pythonhosted.org/packages/f5/4b/1e52baf90187606e33a7b8cfa8f96f5829acd7f01870077eb01059ab76d0/jiter-0.17.0-cp314-cp314t-win_arm64.whl", hash = "sha256:4dfbfe5a6e1e80a7082af559f66386405025ec278833e0c649f69cbc6e1004cc", size = 182776, upload-time = "2026-09-12T15:13:16.239Z" }, + { url = "https://files.pythonhosted.org/packages/05/fc/efe3ac75564ab10f53517958f5ccdc231fc7334af66c76776cb554a88967/jiter-0.17.0-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:84963d3f395ef5e9a32ce47155e08a7962fa292c159a10cb98b931cef1416925", size = 292143, upload-time = "2026-09-12T15:13:17.502Z" }, + { url = "https://files.pythonhosted.org/packages/d1/4c/46982118d91f9ffe9714319d21ec4f98d9b7e0cfd9062826c524a54de24e/jiter-0.17.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:ffa0380ad091de7d3fc33e17a97ff479851ee18a0a2a3ee56ff3215cdc886656", size = 321341, upload-time = "2026-09-12T15:13:19.133Z" }, + { url = "https://files.pythonhosted.org/packages/e7/12/9b1ac6ecc6307049913db54839ddba1c11c1ef72c5a8bbb5514bc3b50d1b/jiter-0.17.0-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:755079792868ce5d4938e83b91a0939b34fb858a1ca65a104f2d771bea57faa1", size = 344383, upload-time = "2026-09-12T15:13:20.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/b6/527cc72af836d824e9d4d666e64f0a1ca7eafd662a8da9657b78592172ba/jiter-0.17.0-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3bf4dc2b84a464117fb097d15a25c58d100d2692888e3b0d92df5b48ed16b7c0", size = 326841, upload-time = "2026-09-12T15:13:21.83Z" }, + { url = "https://files.pythonhosted.org/packages/d1/41/567f98617e88005b249503b933803f633ec6ba2d427cf4cc35e5c832125c/jiter-0.17.0-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:02a360707033d8cef53f7f3480817a1489177a259ec6ec01e98c37e0b922ddca", size = 341354, upload-time = "2026-09-12T15:13:23.323Z" }, + { url = "https://files.pythonhosted.org/packages/40/da/b29cda895b785f7d426e224638a885b6145a08ce853b381f34afe3e88c5d/jiter-0.17.0-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:300ce01ab0215e3dea4d00090143c909aedc65c0f809b3c07983e1d038f291b9", size = 351985, upload-time = "2026-09-12T15:13:26.526Z" }, + { url = "https://files.pythonhosted.org/packages/f7/5c/8a73829e7389e72ea298a450f2b3cb58e71a3e464b45f6d8753740f1c4f5/jiter-0.17.0-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:746243a080b4ca790b8499af3d7cf9825d5f5987933950cd818e767ee353d826", size = 346052, upload-time = "2026-09-12T15:13:27.887Z" }, + { url = "https://files.pythonhosted.org/packages/1d/2f/98d6001026932c095ba440925570123043bed29f5ff56158dfe729a9e81b/jiter-0.17.0-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:b550585523339b71cb852b811aae49d08d7601ad8ffe9f5dc1562f4c3d22fd87", size = 329159, upload-time = "2026-09-12T15:13:31.569Z" }, + { url = "https://files.pythonhosted.org/packages/94/2e/708dc1d2678f092c31c12754e860cd8353e6a85ecbdb1010157edca0da9e/jiter-0.17.0-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:0239520085cac678e77a606fd7e3f1c60c371d719790c5e3807388d3da4354c2", size = 336001, upload-time = "2026-09-12T15:13:32.846Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f0/75a5ae38862f4eaf0fe2f8a9fbf6484c4890df04c06dcdffc45e36bca61a/jiter-0.17.0-cp315-cp315-musllinux_1_1_aarch64.whl", hash = "sha256:eb2295da7c3769f6719b227a237aa6a5cfa6550e478bc838001b592c57e16575", size = 484281, upload-time = "2026-09-12T15:13:35.333Z" }, + { url = "https://files.pythonhosted.org/packages/a0/32/6636fae811c27c7f93e1b11fb5800de6a5c9e4269a27cf718e0b31218ad1/jiter-0.17.0-cp315-cp315-musllinux_1_1_x86_64.whl", hash = "sha256:e088612ff90ebc9247e1a43074b72835804261c47e6a6c01cb3ddcb55360d688", size = 521300, upload-time = "2026-09-12T15:13:37.101Z" }, + { url = "https://files.pythonhosted.org/packages/61/aa/12df7e0b0b1a2602e3d5a5a7104d7d9700f254b400f134a9b50955c4d231/jiter-0.17.0-cp315-cp315-win32.whl", hash = "sha256:0b52d52035b3907c5b1f6277857b29c1cbfc965e24e0f27330dbed83edb591ec", size = 185138, upload-time = "2026-09-12T15:13:38.901Z" }, + { url = "https://files.pythonhosted.org/packages/ba/ec/3dd2e495032cddde05723c1f4c743b67a23e55d2af244692a7f58f0cdae3/jiter-0.17.0-cp315-cp315-win_amd64.whl", hash = "sha256:10f5558eed511b830488003449d942bd75829ad6257dc58cb9a03e596a7777b1", size = 226950, upload-time = "2026-09-12T15:13:40.17Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c7/ef85704e0a57e9cadb2babc05f6d7c5df4a1c75da1a6ee31e1986b0099a5/jiter-0.17.0-cp315-cp315-win_arm64.whl", hash = "sha256:fa13acf1046f95df808c64b1310705e143fab87aee73ae00cc42d640867fd2c1", size = 183618, upload-time = "2026-09-12T15:13:41.432Z" }, + { url = "https://files.pythonhosted.org/packages/0e/9a/a4b348349de68762b58d6713973d363ad80a1c741d0bf8def7975f0ecb26/jiter-0.17.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:af2f7501580f274b63c4b2283bc425f5df7edf06ae5b171e5f87d912ff359a20", size = 321155, upload-time = "2026-09-12T15:13:42.716Z" }, + { url = "https://files.pythonhosted.org/packages/c1/70/aebd6d0b5f0677de3a3d0bdc4a05fac949b97c4ede454c8809f180ac7b17/jiter-0.17.0-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:10c5349312e5cb02b7a21e123a57665afa895953f05bf252a9dd4c13a572b7ab", size = 340985, upload-time = "2026-09-12T15:13:44.115Z" }, + { url = "https://files.pythonhosted.org/packages/a7/82/4c3b49796b5eb62f3f5046f957683f4ba0135fe1a60957c11180512460df/jiter-0.17.0-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:86f3f9343a288eb85a81ef20a752b2f84564296636db54a9fff0b5c8deaf1df2", size = 325670, upload-time = "2026-09-12T15:13:45.901Z" }, + { url = "https://files.pythonhosted.org/packages/bc/43/f6341ecb4872202a4ef150486fcee0e1ace4aa3da39b71b82061452cdd3a/jiter-0.17.0-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4607ec7d93355fbc25b8dc5189153cf21d66063b9f9cd04dd2774e6e783f9b6a", size = 340339, upload-time = "2026-09-12T15:13:47.442Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c4/bc2c86e08fa065e03cb2fbc53b367c3640a7d257ef9d877b29118ea636b7/jiter-0.17.0-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:10cd64a5720ad7f809ac5466ff1705813f1b6b510f195a73acafba0ac0e1f675", size = 350705, upload-time = "2026-09-12T15:13:48.848Z" }, + { url = "https://files.pythonhosted.org/packages/9d/67/91f12aa111cca6e3a197c3e36bf60a034bf9f122f6d41112a639e44217d8/jiter-0.17.0-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:efe9f61bb30174d2f5c8396445c360c96c44e78164d0815dfe627ccf57849574", size = 345011, upload-time = "2026-09-12T15:13:50.215Z" }, + { url = "https://files.pythonhosted.org/packages/f5/cb/9f5556e8f6ec89755fb5a709d8eb8270c9a324e31079eda0dfbeca451b6e/jiter-0.17.0-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:370d8fe5bf201dc6925e8a84c81ac7291f74d9fd1778234fc79d517064a5c76b", size = 328268, upload-time = "2026-09-12T15:13:51.809Z" }, + { url = "https://files.pythonhosted.org/packages/22/98/153f20680fb75781a490fb849940e2b00f95035c7aa054df592f36ed33fc/jiter-0.17.0-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6b303d88e6a0bda789ec4b7801c7bad68e27230ba1fe4baffc756d1fbd32dc9d", size = 337024, upload-time = "2026-09-12T15:13:53.095Z" }, + { url = "https://files.pythonhosted.org/packages/af/59/b16c9be3a5035df4466cc72e888188c027562de90a723d290ab6814cb9d4/jiter-0.17.0-cp315-cp315t-musllinux_1_1_aarch64.whl", hash = "sha256:30793a24a31e968969757c9e08d830cbb15a2cd3c4959b4498b38f4b1c2258eb", size = 482766, upload-time = "2026-09-12T15:13:55.713Z" }, + { url = "https://files.pythonhosted.org/packages/d0/55/667dea313094024bef082175d6bfe8976f90d1c00c926af9df1d8e0eab48/jiter-0.17.0-cp315-cp315t-musllinux_1_1_x86_64.whl", hash = "sha256:686c93d86f2b426c803024b805bd161a6cd10e9627c23e901640eab646c0ad8a", size = 520367, upload-time = "2026-09-12T15:13:57.674Z" }, + { url = "https://files.pythonhosted.org/packages/21/e3/4b1a43501fb9ed17b01d137e380cb0e8fdcb39a254ce31aa2ab95bc861ac/jiter-0.17.0-cp315-cp315t-win32.whl", hash = "sha256:86d703d9faa1ffc8ae4e9de0fa007712ed2171b5c0d93811a8e2e105ac729b0d", size = 184603, upload-time = "2026-09-12T15:13:59.27Z" }, + { url = "https://files.pythonhosted.org/packages/f9/f2/b8ee0372b6ebdf1bde5cc44495d5291d17f961065f5b48f8616cc67cac2e/jiter-0.17.0-cp315-cp315t-win_amd64.whl", hash = "sha256:42b0260445251b1bc520a63baa94a32d88e0f931fba234f1764db7feb7c72174", size = 227936, upload-time = "2026-09-12T15:14:00.472Z" }, + { url = "https://files.pythonhosted.org/packages/a4/b4/923a1215daba959aed8355973315cb3f81f53e0d01c5b211870a27b41f45/jiter-0.17.0-cp315-cp315t-win_arm64.whl", hash = "sha256:d47687806f9c54c84ea38733507081337922beca90ce819c7d852dd485bc0f23", size = 182977, upload-time = "2026-09-12T15:14:01.799Z" }, + { url = "https://files.pythonhosted.org/packages/b9/3b/05a917204413e2e09906dfa35240c1021227aeb56c7305abea9562c598b4/jiter-0.17.0-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:eaba834b72d573547b9d966465b3394b749d5e14208cc70acb63aca37619ab33", size = 288143, upload-time = "2026-09-12T15:14:02.998Z" }, + { url = "https://files.pythonhosted.org/packages/d9/e1/a1cd3c0cf8f79945939e4f8caae9990529f67d7f77f671769f73956329b9/jiter-0.17.0-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:51e1519d676a9f14dad9c2a411170d43b022ddb7989562df4e849b261ce127b2", size = 281847, upload-time = "2026-09-12T15:14:04.414Z" }, + { url = "https://files.pythonhosted.org/packages/72/b4/9b797679e09f4a46c32986aeb3670bd9bc562fc0b373c7a0ee5c5dce1206/jiter-0.17.0-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d0ce4feb52493e3513335b2accdcd75605652e4632772d3c8c2f7b86954d7f39", size = 304797, upload-time = "2026-09-12T15:14:05.733Z" }, + { url = "https://files.pythonhosted.org/packages/25/4a/0d77415b27a00d970e4e710f7c1de62e96a11c4cab3ed1add0015af04626/jiter-0.17.0-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:29f49b325e0234e4ad9ecca5b861ffbd09b95ccac9bd46fa55841b6e56eea5fe", size = 307815, upload-time = "2026-09-12T15:14:07.105Z" }, + { url = "https://files.pythonhosted.org/packages/17/31/4bb27f54333d3b9ef1e5bd3312dc0b4bbe59c68bb0885fdb40583a6b1567/jiter-0.17.0-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:454c4997d73cc466c71fd565d91e603b0274e48ea0c6b0b7a7aee6967e4ceb7c", size = 288415, upload-time = "2026-09-12T15:14:08.455Z" }, + { url = "https://files.pythonhosted.org/packages/28/30/879570ecf82574eaea77c5eb10309f4b630dece5f2a556e9814a90ba3f2d/jiter-0.17.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:40d2c240f8f80b5b0f201b29f0ae129c81448c60c772227a41747b5e0026f6a2", size = 279113, upload-time = "2026-09-12T15:14:10.117Z" }, + { url = "https://files.pythonhosted.org/packages/77/7a/1f0b8a35fbd079a4f1752c31a15dc99cf277f863747c459be0af39e900e5/jiter-0.17.0-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3e05f5adbf68c4bd11e1610f394034d984152988e84be6f8314235ce6f2139e5", size = 303708, upload-time = "2026-09-12T15:14:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/e1/8b/d76219ebdbcf3d4209d9d21a0810db4c8d0a6f88e3ee87d30bdea4e90d30/jiter-0.17.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2c0bf24c72fd0491405dce5d40194f2070e9021ce648c1a1d46234b93d848ff", size = 307147, upload-time = "2026-09-12T15:14:12.897Z" }, +] + [[package]] name = "jsonschema" version = "4.26.0" @@ -521,6 +715,14 @@ dependencies = [ { name = "typing-extensions" }, ] +[package.optional-dependencies] +anthropic = [ + { name = "anthropic" }, +] +openai = [ + { name = "openai" }, +] + [package.dev-dependencies] dev = [ { name = "anyio" }, @@ -537,10 +739,13 @@ dev = [ [package.metadata] requires-dist = [ + { name = "anthropic", marker = "extra == 'anthropic'", specifier = ">=1.15.0,<2" }, { name = "httpx", specifier = ">=0.27,<1" }, + { name = "openai", marker = "extra == 'openai'", specifier = ">=3.35.1,<4" }, { name = "pydantic", specifier = ">=2.11,<3" }, { name = "typing-extensions", specifier = ">=4.12.2" }, ] +provides-extras = ["anthropic", "openai"] [package.metadata.requires-dev] dev = [ @@ -762,6 +967,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, ] +[[package]] +name = "openai" +version = "3.35.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "httpx2" }, + { name = "jiter" }, + { name = "pydantic" }, + { name = "sniffio" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/87/4c/609b16f2a6e9a99f27a82ce93ff75868e7cb364fe6daff50cc0c1d620772/openai-3.35.1.tar.gz", hash = "sha256:b33e6561b90586e450def8a388e7d364755325da6884fc71348be5542e043bd0", size = 2250942, upload-time = "2026-10-18T01:06:38.964Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/7b/6d7e70f0762d1b286051bd7cb2fd0c364c1830d0f8826738673aeec05c2b/openai-3.35.1-py3-none-any.whl", hash = "sha256:264fd0fafc889021f526b7c1e5a2df5ca3e8dea874738ecd31da666434f25967", size = 2280230, upload-time = "2026-10-18T01:06:36.16Z" }, +] + [[package]] name = "openfeature-provider-ofrep" version = "0.3.0" @@ -784,6 +1006,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/da/44/8a4f5225e930ff0d999fd43f5d743a4babeb6c7e76dddc00f0e118878ef3/openfeature_sdk-0.10.0-py3-none-any.whl", hash = "sha256:75497ea75d73f684eef509a25f79ad6386368862e050af80ab70a44ae49b33e4", size = 38941, upload-time = "2026-06-01T19:45:33.011Z" }, ] +[[package]] +name = "opentelemetry-api" +version = "1.45.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2e/02/6e0ae9cc61bd3169d401077b507b3ebc344745171e1051ab430be012dcd9/opentelemetry_api-1.45.1.tar.gz", hash = "sha256:aa38ed19bcc084ba42782a73255b3582283eced7ad6dddbd6695189e69adfb75", size = 72804, upload-time = "2026-10-06T17:32:58.133Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/41/f7dcf80b81ee8e71c1a2b59f14208bc723edbd89ed027a73b175abf6348e/opentelemetry_api-1.45.1-py3-none-any.whl", hash = "sha256:b31553efa588ae44bc306f863c785c5333a9ecc091248c6ee68b4b6c87fdedfb", size = 60256, upload-time = "2026-10-06T17:32:33.506Z" }, +] + [[package]] name = "packaging" version = "26.3" @@ -1208,7 +1442,8 @@ version = "2026.6.3" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.15'", - "python_full_version >= '3.11' and python_full_version < '3.15'", + "python_full_version >= '3.12' and python_full_version < '3.15' and sys_platform == 'emscripten'", + "(python_full_version >= '3.11' and python_full_version < '3.15' and sys_platform != 'emscripten') or (python_full_version == '3.11.*' and sys_platform == 'emscripten')", ] sdist = { url = "https://files.pythonhosted.org/packages/aa/2a/9618a122aeb2a169a28b03889a2995fe297588964333d4a7d67bdf46e147/rpds_py-2026.6.3.tar.gz", hash = "sha256:1cebd1337c242e4ec2293e541f712b2da849b29f48f0c293684b71c0632625d4", size = 64051, upload-time = "2026-06-30T07:17:53.009Z" } wheels = [ @@ -1354,6 +1589,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8b/4b/51327018d056f0dad2c2238f26d1fb0f53707a9d91b75dea6d1b3039f136/ruff-0.16.7-py3-none-win_arm64.whl", hash = "sha256:aab7f39e2c9df6c596216070f98eef1207b94f8516cca20c808826974971855b", size = 10412401, upload-time = "2026-09-10T18:04:04.098Z" }, ] +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, +] + [[package]] name = "tomli" version = "2.4.1" @@ -1408,6 +1652,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, ] +[[package]] +name = "truststore" +version = "0.10.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/9f/c5201d42a484c061e528825fc8e2d565f5abd50a4ced6fb7d29c4ec99b2b/truststore-0.10.5.tar.gz", hash = "sha256:30d36967ccaded5cbb38d602c433f53600036c79d502f4533a49b60a03bbefcd", size = 28091, upload-time = "2026-10-12T22:27:31.808Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/e9/3a7820be2bb0fe53b6bc9c3be26d3d1158004e4c3ab953aa6840b955b1e9/truststore-0.10.5-py3-none-any.whl", hash = "sha256:9aaaedaefaf06d8b206278cf8b5012bc897f485a874503501e12d776df78951c", size = 19017, upload-time = "2026-10-12T22:27:30.377Z" }, +] + [[package]] name = "types-jsonschema" version = "4.26.0.20260518" From 927db8afc4a21b015cc2113ef76c078cdf77ec09 Mon Sep 17 00:00:00 2001 From: tomflenner Date: Mon, 19 Oct 2026 12:41:39 +0200 Subject: [PATCH 3/8] feat(ai): regenerate contract and expose AI management resources Signed-off-by: tomflenner --- CHANGELOG.md | 6 + README.md | 10 + openapi/coverage.yaml | 13 + openapi/openapi.yaml | 2483 +++++++++++++++-- openapi/platform-openapi.yaml | 4 +- openapi/source.yaml | 6 +- scripts/generate_models.py | 21 +- src/kaitencloud/_ai_models.py | 73 +- src/kaitencloud/_async/client.py | 2 + src/kaitencloud/_async/resources/__init__.py | 2 + src/kaitencloud/_async/resources/instances.py | 47 + src/kaitencloud/_async/resources/licenses.py | 87 +- src/kaitencloud/_async/resources/models.py | 96 + src/kaitencloud/_sync/client.py | 2 + src/kaitencloud/_sync/resources/__init__.py | 2 + src/kaitencloud/_sync/resources/instances.py | 45 + src/kaitencloud/_sync/resources/licenses.py | 85 +- src/kaitencloud/_sync/resources/models.py | 98 + src/kaitencloud/types/__init__.py | 54 +- src/kaitencloud/types/_core.py | 332 ++- src/kaitencloud/types/_params.py | 15 +- src/kaitencloud/types/_webhooks.py | 142 +- tests/test_contract.py | 81 + tests/test_models.py | 36 + 24 files changed, 3337 insertions(+), 405 deletions(-) create mode 100644 src/kaitencloud/_async/resources/models.py create mode 100644 src/kaitencloud/_sync/resources/models.py diff --git a/CHANGELOG.md b/CHANGELOG.md index f465553..348bffd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,12 @@ public API, a minor version adds to it, and a patch version fixes it. ### Added +- Regenerated the AI credits contract, including write-only `ReportUsageValue`, + typed usage outcomes/gauges and model/conversion-rule events. Original AI + response bridge names now alias generated models. +- Model catalogue CRUD, license conversion-rule management, paged inference + history and open reservation reads on both clients. + - Optional OpenAI and Anthropic drop-in clients and existing-client wrappers, with allow-listed reserve/call/commit accounting, sync/async streaming settlement, pluggable estimates and provider timeout-based reservation lifetimes. diff --git a/README.md b/README.md index 53947f0..6133437 100644 --- a/README.md +++ b/README.md @@ -848,3 +848,13 @@ with normalized usage; pricing stays on the server. See [AI wrapper behavior and limits](internal-docs/AI_WRAPPERS.md), especially stream closure, missing audio usage and agent SDK gating constraints. + +AI management uses `client.models` (catalogue list/get/create/update/delete) and +`client.licenses.list_conversion_rules`, `create_conversion_rule`, +`get_conversion_rule`, `update_conversion_rule`, `archive_conversion_rule`. +Pass `ConversionRates` and `VendorCosts` from `kaitencloud.types`; decimal rates +are strings, and no pricing calculation runs in the SDK. Inference history is +one page at a time via `instances.list_ai_inferences(..., cursor=...)`; its +`next_cursor` continues the history. `instances.list_ai_reservations` returns +open reservations. Usage gauges now expose generated `reserved`/`ai` fields. +The vendored contract's exact revision is recorded in `openapi/source.yaml`. diff --git a/openapi/coverage.yaml b/openapi/coverage.yaml index 19fef99..eb801d5 100644 --- a/openapi/coverage.yaml +++ b/openapi/coverage.yaml @@ -11,6 +11,19 @@ core: operations: + listModels: models.list + createModel: models.create + getModel: models.get + updateModel: models.update + deleteModel: models.delete + listConversionRules: licenses.list_conversion_rules + createConversionRule: licenses.create_conversion_rule + getConversionRule: licenses.get_conversion_rule + updateConversionRule: licenses.update_conversion_rule + archiveConversionRule: licenses.archive_conversion_rule + listAiInferences: instances.list_ai_inferences + listAiReservations: instances.list_ai_reservations + # components list-components: components.list get-component: components.get diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml index e09e19c..42a5d70 100644 --- a/openapi/openapi.yaml +++ b/openapi/openapi.yaml @@ -1,5 +1,221 @@ components: schemas: + AiCredits: + additionalProperties: false + properties: + charged: + type: string + computed: + type: string + reserved: + type: string + type: object + AiInference: + additionalProperties: false + properties: + authorized: + type: boolean + committedAt: + format: date-time + type: string + conversionRuleId: + type: string + credits: + $ref: "#/components/schemas/AiCredits" + denialReason: + type: string + expiresAt: + format: date-time + type: string + model: + type: string + modelSlug: + type: string + reportedAt: + format: date-time + type: string + requestStatus: + type: string + requestType: + enum: + - COMPLETION + - EMBEDDING + - IMAGE_GENERATION + - AUDIO + - VIDEO + type: string + status: + enum: + - RESERVED + - COMMITTED + - RELEASED + - EXPIRED + type: string + transactionId: + type: string + usage: + $ref: "#/components/schemas/AiUsage" + valueAfter: + format: double + type: number + valueBefore: + format: double + type: number + windowStart: + format: date-time + type: string + required: + - transactionId + - status + - reportedAt + - model + - requestType + - usage + - credits + type: object + AiInferencePage: + additionalProperties: false + properties: + hasMore: + type: boolean + items: + items: + $ref: "#/components/schemas/AiInference" + type: + - array + - "null" + nextCursor: + type: string + required: + - items + - hasMore + type: object + AiReservations: + additionalProperties: false + properties: + items: + items: + $ref: "#/components/schemas/AiInference" + type: + - array + - "null" + required: + - items + type: object + AiUsage: + additionalProperties: false + properties: + cacheReadInputTokens: + format: int64 + minimum: 0 + type: integer + cacheWriteInputTokens: + format: int64 + minimum: 0 + type: integer + durationMs: + format: int64 + minimum: 0 + type: integer + inputTokens: + format: int64 + minimum: 0 + type: integer + outputTokens: + format: int64 + minimum: 0 + type: integer + unitType: + enum: + - image + - second + - frame + type: string + units: + pattern: ^[0-9]{1,12}(\.[0-9]{1,6})?$ + type: string + type: object + AiUsageOutcome: + additionalProperties: false + properties: + authorized: + type: boolean + credits: + $ref: "#/components/schemas/AiCredits" + expiresAt: + format: date-time + type: string + phase: + enum: + - reserve + - commit + type: string + remaining: + type: + - string + - "null" + status: + enum: + - RESERVED + - COMMITTED + - RELEASED + type: string + transactionId: + type: string + warnings: + items: + type: string + type: array + required: + - transactionId + - phase + - status + - credits + - remaining + - warnings + type: object + AiUsageValue: + additionalProperties: false + properties: + model: + maxLength: 255 + minLength: 1 + type: string + phase: + enum: + - reserve + - commit + type: string + requestStatus: + enum: + - SUCCESS + - PROVIDER_ERROR + - CUSTOMER_ERROR + - NOT_SENT + type: string + requestType: + enum: + - COMPLETION + - EMBEDDING + - IMAGE_GENERATION + - AUDIO + - VIDEO + type: string + transactionId: + type: string + ttlSeconds: + format: int64 + type: integer + type: + enum: + - ai_usage + type: string + usage: + $ref: "#/components/schemas/AiUsage" + required: + - type + - phase + type: object AuditTrail: additionalProperties: false properties: @@ -296,7 +512,7 @@ components: format: date-time type: string available: - description: Whether this deployment has the connector registered + description: Whether this installation has the connector registered type: boolean connectorName: description: Stable connector identifier @@ -304,7 +520,7 @@ components: - kaiten.integration.crm.attio type: string entitled: - description: Whether this organization's licence includes the connector. True for an ungated connector. + description: Whether this organization's license includes the connector. True for an ungated connector. type: boolean version: description: Registered connector version. Absent when the connector is not available. @@ -317,6 +533,168 @@ components: - entitled - activated type: object + ConversionRates: + additionalProperties: false + properties: + perCacheWriteToken: + pattern: ^[0-9]{1,8}(\.[0-9]{1,10})?$ + type: + - string + - "null" + perCachedInputToken: + pattern: ^[0-9]{1,8}(\.[0-9]{1,10})?$ + type: + - string + - "null" + perInputToken: + pattern: ^[0-9]{1,8}(\.[0-9]{1,10})?$ + type: + - string + - "null" + perOutputToken: + pattern: ^[0-9]{1,8}(\.[0-9]{1,10})?$ + type: + - string + - "null" + perRequest: + pattern: ^[0-9]{1,8}(\.[0-9]{1,10})?$ + type: + - string + - "null" + perSecond: + pattern: ^[0-9]{1,8}(\.[0-9]{1,10})?$ + type: + - string + - "null" + perUnit: + pattern: ^[0-9]{1,8}(\.[0-9]{1,10})?$ + type: + - string + - "null" + type: object + ConversionRule: + additionalProperties: false + properties: + chargeOnError: + enum: + - NEVER + - ALWAYS + - PROVIDER_ERROR_ONLY + type: string + createdAt: + format: date-time + type: string + entitlementSlug: + type: string + grantedByLicense: + type: boolean + id: + format: uuid + type: string + licenseSlug: + type: string + maximumCreditsPerRequest: + pattern: ^[0-9]{1,12}(\.[0-9]{1,6})?$ + type: + - string + - "null" + minimumCreditsPerRequest: + pattern: ^[0-9]{1,12}(\.[0-9]{1,6})?$ + type: string + modelSlug: + type: + - string + - "null" + rates: + $ref: "#/components/schemas/ConversionRates" + requestType: + enum: + - COMPLETION + - EMBEDDING + - IMAGE_GENERATION + - AUDIO + - VIDEO + - null + type: + - string + - "null" + status: + enum: + - ACTIVE + - ARCHIVED + type: string + updatedAt: + format: date-time + type: string + required: + - id + - licenseSlug + - entitlementSlug + - modelSlug + - requestType + - rates + - minimumCreditsPerRequest + - maximumCreditsPerRequest + - chargeOnError + - status + - grantedByLicense + - createdAt + - updatedAt + type: object + ConversionRuleUpdated: + additionalProperties: false + properties: + after: + $ref: "#/components/schemas/ConversionRule" + before: + $ref: "#/components/schemas/ConversionRule" + required: + - before + - after + type: object + CreateConversionRuleBody: + additionalProperties: false + properties: + chargeOnError: + default: NEVER + enum: + - NEVER + - ALWAYS + - PROVIDER_ERROR_ONLY + type: string + entitlementSlug: + minLength: 1 + type: string + maximumCreditsPerRequest: + pattern: ^[0-9]{1,12}(\.[0-9]{1,6})?$ + type: + - string + - "null" + minimumCreditsPerRequest: + default: "0" + pattern: ^[0-9]{1,12}(\.[0-9]{1,6})?$ + type: string + modelSlug: + type: + - string + - "null" + rates: + $ref: "#/components/schemas/ConversionRates" + requestType: + enum: + - COMPLETION + - EMBEDDING + - IMAGE_GENERATION + - AUDIO + - VIDEO + - null + type: + - string + - "null" + required: + - entitlementSlug + - rates + type: object Customer: additionalProperties: false properties: @@ -907,6 +1285,9 @@ components: EntitlementUsage: additionalProperties: false properties: + ai: + $ref: "#/components/schemas/AiUsageOutcome" + description: Outcome of this AI report; absent on ordinary reads currentPeriodEnd: description: End of the current usage window (exclusive). Null for a lifetime entitlement (no configured reset period). examples: @@ -951,6 +1332,9 @@ components: - $ref: "#/components/schemas/NumberEntitlementValue" - $ref: "#/components/schemas/BooleanEntitlementValue" - $ref: "#/components/schemas/ConfigEntitlementValue" + reserved: + description: Open, unexpired reserved credits; present only on AI credit pools + type: string value: description: Resolved entitlement value, discriminated by the 'type' field discriminator: @@ -1032,6 +1416,23 @@ components: required: - key type: object + EventHeaders: + additionalProperties: true + properties: + baggage: + description: W3C baggage of that request, when it carried any + type: string + instance_id: + description: The instance the event concerns, set on the events of one instance. The usage events' payloads do not name it. + format: uuid + type: string + traceparent: + description: W3C trace context of the request that produced the event + type: string + tracestate: + description: W3C trace state of that request, when it carried one + type: string + type: object FeatureFlag: additionalProperties: false properties: @@ -1298,6 +1699,23 @@ components: - startLicenseDate - endLicenseDate type: object + InstanceAIModelUsageUnauthorized: + additionalProperties: false + properties: + entitlement_slug: + type: string + model: + type: string + reason: + type: string + request_type: + type: string + required: + - entitlement_slug + - model + - request_type + - reason + type: object InstanceBody: additionalProperties: false properties: @@ -2096,28 +2514,107 @@ components: required: - jsonSchema type: object - Notification: + Model: additionalProperties: false properties: - actionUrl: - description: Where clicking the notification goes - type: string - body: - description: Rendered detail line, when the event has one - type: string createdAt: - description: When the event occurred format: date-time + readOnly: true type: string - eventName: - description: Event this notification reports - examples: - - INSTANCE_DEPLOYED - type: string - eventType: - description: CloudEvents type of the same event -- the key it is published under in this document's webhooks section, so a client can line a notification up with the payload schema and the description already published there - examples: - - com.kaiten.instance.v1.deployed + description: + type: string + externalIds: + description: Exact provider identifiers, unique per organization; cannot shadow another model's slug + items: + type: string + type: array + uniqueItems: true + id: + readOnly: true + type: string + metadata: + additionalProperties: {} + type: object + modality: + enum: + - TEXT + - IMAGE + - AUDIO + - VIDEO + - EMBEDDING + - MULTIMODAL + type: string + name: + maxLength: 100 + minLength: 1 + type: string + slug: + description: Immutable URL-friendly identifier, unique per organization + maxLength: 100 + minLength: 2 + pattern: ^[a-z0-9][a-z0-9-]*[a-z0-9]$ + type: string + status: + default: ACTIVE + description: ACTIVE authorizes usage; DEPRECATED keeps existing rules; ARCHIVED authorizes none. Reversible. + enum: + - ACTIVE + - DEPRECATED + - ARCHIVED + type: string + updatedAt: + format: date-time + readOnly: true + type: string + vendor: + maxLength: 100 + minLength: 1 + type: string + vendorCosts: + $ref: "#/components/schemas/VendorCosts" + required: + - id + - name + - slug + - vendor + - modality + - status + - createdAt + - updatedAt + type: object + ModelUpdated: + additionalProperties: false + properties: + after: + $ref: "#/components/schemas/Model" + before: + $ref: "#/components/schemas/Model" + required: + - before + - after + type: object + Notification: + additionalProperties: false + properties: + actionUrl: + description: Where clicking the notification goes + type: string + body: + description: Rendered detail line, when the event has one + type: string + createdAt: + description: When the event occurred + format: date-time + type: string + eventName: + description: Event this notification reports + examples: + - INSTANCE_DEPLOYED + type: string + eventType: + description: CloudEvents type of the same event -- the key it is published under in this document's webhooks section, so a client can line a notification up with the payload schema and the description already published there + examples: + - com.kaiten.instance.v1.deployed type: string id: description: Identifier of the underlying audit trail entry @@ -2373,6 +2870,23 @@ components: - items - hasMore type: object + PageModel: + additionalProperties: false + properties: + hasMore: + description: Whether more rows exist beyond this page. + type: boolean + items: + items: + $ref: "#/components/schemas/Model" + type: array + nextCursor: + description: Opaque cursor for the next page. Absent when hasMore is false. + type: string + required: + - items + - hasMore + type: object PageRelease: additionalProperties: false properties: @@ -2524,7 +3038,7 @@ components: - com.kaiten.instance.v1.deployed type: string group: - description: Catalogue group the client renders this under + description: Catalog group the client renders this under examples: - deployments type: string @@ -2542,7 +3056,7 @@ components: additionalProperties: false properties: channels: - description: Channels this deployment serves, in display order + description: Channels this installation serves, in display order items: type: string type: array @@ -2726,14 +3240,12 @@ components: description: Reported entitlement value, discriminated by the 'type' field. Usage reporting accepts the number variant only. discriminator: mapping: - boolean: "#/components/schemas/BooleanEntitlementValue" + ai_usage: "#/components/schemas/AiUsageValue" number: "#/components/schemas/NumberEntitlementValue" - object: "#/components/schemas/ConfigEntitlementValue" propertyName: type oneOf: - $ref: "#/components/schemas/NumberEntitlementValue" - - $ref: "#/components/schemas/BooleanEntitlementValue" - - $ref: "#/components/schemas/ConfigEntitlementValue" + - $ref: "#/components/schemas/AiUsageValue" required: - value type: object @@ -2961,6 +3473,11 @@ components: description: description: What this value means, shown by the editor type: string + entries: + description: Shape of each known key, for a map whose entries differ. Named after the key + items: + $ref: "#/components/schemas/TargetingContextNode" + type: array fields: description: Named children, for an object items: @@ -3150,6 +3667,42 @@ components: - createdAt - createdBy type: object + UpdateConversionRuleBody: + additionalProperties: false + properties: + chargeOnError: + default: NEVER + enum: + - NEVER + - ALWAYS + - PROVIDER_ERROR_ONLY + type: string + entitlementSlug: + type: + - string + - "null" + maximumCreditsPerRequest: + pattern: ^[0-9]{1,12}(\.[0-9]{1,6})?$ + type: + - string + - "null" + minimumCreditsPerRequest: + default: "0" + pattern: ^[0-9]{1,12}(\.[0-9]{1,6})?$ + type: string + modelSlug: + type: + - string + - "null" + rates: + $ref: "#/components/schemas/ConversionRates" + requestType: + type: + - string + - "null" + required: + - rates + type: object User: additionalProperties: false properties: @@ -3179,6 +3732,35 @@ components: - description - value type: object + VendorCosts: + additionalProperties: false + properties: + currency: + default: USD + enum: + - USD + type: string + perCacheWriteToken: + pattern: ^[0-9]{1,6}(\.[0-9]{1,12})?$ + type: string + perCachedInputToken: + pattern: ^[0-9]{1,6}(\.[0-9]{1,12})?$ + type: string + perInputToken: + pattern: ^[0-9]{1,6}(\.[0-9]{1,12})?$ + type: string + perOutputToken: + pattern: ^[0-9]{1,6}(\.[0-9]{1,12})?$ + type: string + perRequest: + pattern: ^[0-9]{1,6}(\.[0-9]{1,12})?$ + type: string + perUnit: + pattern: ^[0-9]{1,6}(\.[0-9]{1,12})?$ + type: string + required: + - currency + type: object securitySchemes: bearerAuth: bearerFormat: JWT @@ -3194,6 +3776,7 @@ components: - read:instances - read:licenses - read:metadata_fields + - read:models - read:notifications - read:organizations - read:releases @@ -3207,6 +3790,7 @@ components: - write:instances - write:licenses - write:metadata_fields + - write:models - write:notifications - write:organizations - write:releases @@ -3707,7 +4291,7 @@ paths: tags: - connectors put: - description: "Activates a registered connector for the authenticated organization. Idempotent: activating an already-active connector succeeds without changing when it was first activated. Refused when the organization's licence does not include the connector." + description: "Activates a registered connector for the authenticated organization. Idempotent: activating an already-active connector succeeds without changing when it was first activated. Refused when the organization's license does not include the connector." operationId: activate-connector parameters: - description: Stable connector name @@ -4023,7 +4607,7 @@ paths: - connectors /connectors/{connectorName}/state: get: - description: "Returns whether the connector is registered in this deployment, whether the authenticated organization's licence includes it, and whether the organization has activated it. Answers 200 with `available: false` for a connector this deployment does not have." + description: "Returns whether the connector is registered on this installation, whether the authenticated organization's license includes it, and whether the organization has activated it. Answers 200 with `available: false` for a connector this installation does not have." operationId: get-connector-state parameters: - description: Stable connector name @@ -5614,7 +6198,7 @@ paths: - entitlements /entitlements/{entitlementSlug}: delete: - description: Delete an entitlement by its slug + description: "Delete an entitlement by its slug. Its usage is deleted with it. An entitlement a license still grants is refused with a 409: remove the grants first." operationId: delete-entitlement parameters: - in: path @@ -5649,6 +6233,12 @@ paths: schema: $ref: "#/components/schemas/Problem" description: Not Found + "409": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Conflict "422": content: application/problem+json: @@ -6165,7 +6755,7 @@ paths: tags: - featureflags put: - description: Update a featureflag with the provided details + description: Update a featureflag with the provided details. Sending a slug renames the flag; omitting it keeps the current one. operationId: update-feature-flag parameters: - in: path @@ -6884,6 +7474,10 @@ paths: schema: $ref: "#/components/schemas/EntitlementUsage" description: OK + headers: + Idempotent-Replayed: + schema: + type: string "400": content: application/problem+json: @@ -6932,32 +7526,67 @@ paths: summary: Report entitlement usage metric for an instance tags: - instances - /instances/{instanceSlug}/integrations/{integrationName}: - delete: - description: Delete a single integration for an instance - operationId: delete-instance-integration + /instances/{instanceSlug}/entitlements/{entitlementSlug}/usage/inferences: + get: + operationId: listAiInferences parameters: - - description: Instance slug - in: path + - in: path name: instanceSlug required: true schema: - description: Instance slug - examples: - - instance-slug type: string - - description: Integration adapter name - in: path - name: integrationName + - in: path + name: entitlementSlug required: true schema: - description: Integration adapter name - examples: - - kaiten.integration.crm.attio type: string + - explode: false + in: query + name: from + schema: + format: date-time + type: string + - explode: false + in: query + name: to + schema: + format: date-time + type: string + - explode: false + in: query + name: status + schema: + enum: + - RESERVED + - COMMITTED + - RELEASED + - EXPIRED + type: string + - explode: false + in: query + name: transactionId + schema: + type: string + - explode: false + in: query + name: cursor + schema: + type: string + - explode: false + in: query + name: limit + schema: + format: int32 + maximum: 500 + minimum: 1 + type: integer responses: - "204": - description: No Content + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/AiInferencePage" + description: OK "400": content: application/problem+json: @@ -6996,38 +7625,30 @@ paths: description: Internal Server Error security: - bearerAuth: - - write:instances - summary: Delete an instance integration + - read:instances + summary: List AI inferences tags: - instances + /instances/{instanceSlug}/entitlements/{entitlementSlug}/usage/reservations: get: - description: Retrieve a single integration for an instance - operationId: get-instance-integration + operationId: listAiReservations parameters: - - description: Instance slug - in: path + - in: path name: instanceSlug required: true schema: - description: Instance slug - examples: - - instance-slug type: string - - description: Integration adapter name - in: path - name: integrationName + - in: path + name: entitlementSlug required: true schema: - description: Integration adapter name - examples: - - kaiten.integration.crm.attio type: string responses: "200": content: application/json: schema: - $ref: "#/components/schemas/InstanceIntegration" + $ref: "#/components/schemas/AiReservations" description: OK "401": content: @@ -7062,12 +7683,13 @@ paths: security: - bearerAuth: - read:instances - summary: Get an instance integration + summary: List open AI reservations tags: - instances - post: - description: Create a single integration for an instance - operationId: create-instance-integration + /instances/{instanceSlug}/integrations/{integrationName}: + delete: + description: Delete a single integration for an instance + operationId: delete-instance-integration parameters: - description: Instance slug in: path @@ -7087,20 +7709,9 @@ paths: examples: - kaiten.integration.crm.attio type: string - requestBody: - content: - application/json: - schema: - $ref: "#/components/schemas/InstanceIntegration" - description: Integration payload - required: true responses: - "201": - content: - application/json: - schema: - $ref: "#/components/schemas/InstanceIntegration" - description: Created + "204": + description: No Content "400": content: application/problem+json: @@ -7125,12 +7736,6 @@ paths: schema: $ref: "#/components/schemas/Problem" description: Not Found - "409": - content: - application/problem+json: - schema: - $ref: "#/components/schemas/Problem" - description: Conflict "422": content: application/problem+json: @@ -7146,12 +7751,12 @@ paths: security: - bearerAuth: - write:instances - summary: Create an instance integration + summary: Delete an instance integration tags: - instances - put: - description: Update a single integration for an instance - operationId: update-instance-integration + get: + description: Retrieve a single integration for an instance + operationId: get-instance-integration parameters: - description: Instance slug in: path @@ -7171,13 +7776,6 @@ paths: examples: - kaiten.integration.crm.attio type: string - requestBody: - content: - application/json: - schema: - $ref: "#/components/schemas/InstanceIntegration" - description: Integration payload - required: true responses: "200": content: @@ -7185,12 +7783,6 @@ paths: schema: $ref: "#/components/schemas/InstanceIntegration" description: OK - "400": - content: - application/problem+json: - schema: - $ref: "#/components/schemas/Problem" - description: Bad Request "401": content: application/problem+json: @@ -7209,12 +7801,174 @@ paths: schema: $ref: "#/components/schemas/Problem" description: Not Found - "409": + "422": content: application/problem+json: schema: $ref: "#/components/schemas/Problem" - description: Conflict + description: Unprocessable Entity + "500": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Internal Server Error + security: + - bearerAuth: + - read:instances + summary: Get an instance integration + tags: + - instances + post: + description: Create a single integration for an instance + operationId: create-instance-integration + parameters: + - description: Instance slug + in: path + name: instanceSlug + required: true + schema: + description: Instance slug + examples: + - instance-slug + type: string + - description: Integration adapter name + in: path + name: integrationName + required: true + schema: + description: Integration adapter name + examples: + - kaiten.integration.crm.attio + type: string + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/InstanceIntegration" + description: Integration payload + required: true + responses: + "201": + content: + application/json: + schema: + $ref: "#/components/schemas/InstanceIntegration" + description: Created + "400": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Bad Request + "401": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unauthorized + "403": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Forbidden + "404": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Not Found + "409": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Conflict + "422": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unprocessable Entity + "500": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Internal Server Error + security: + - bearerAuth: + - write:instances + summary: Create an instance integration + tags: + - instances + put: + description: Update a single integration for an instance + operationId: update-instance-integration + parameters: + - description: Instance slug + in: path + name: instanceSlug + required: true + schema: + description: Instance slug + examples: + - instance-slug + type: string + - description: Integration adapter name + in: path + name: integrationName + required: true + schema: + description: Integration adapter name + examples: + - kaiten.integration.crm.attio + type: string + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/InstanceIntegration" + description: Integration payload + required: true + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/InstanceIntegration" + description: OK + "400": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Bad Request + "401": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unauthorized + "403": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Forbidden + "404": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Not Found + "409": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Conflict "422": content: application/problem+json: @@ -7769,7 +8523,7 @@ paths: description: |- Create a new license with the provided details. Without familySlug or familyId this creates a new license family and the license becomes its version 1. With one of them it adds the next version to that family (familySlug names it by slug, familyId by identifier; sent together they must agree), and the version number is assigned by the server as the next one in the family's sequence. The version starts PUBLISHED, or DRAFT when lifecycleState says so; ARCHIVED is refused (422 CreateLicense.LifecycleStateNotSettable), because a version is archived with archive-license once it has been on sale. - Where the deployment limits how many licenses an organization may have, only a new family counts against that limit (409 CreateLicense.EntitlementLimitReached once it is reached): a new version of an existing family never does. + Where the installation limits how many licenses an organization may have, only a new family counts against that limit (409 CreateLicense.EntitlementLimitReached once it is reached): a new version of an existing family never does. operationId: create-license requestBody: content: @@ -8098,43 +8852,44 @@ paths: summary: Archive a license version tags: - licenses - /licenses/{licenseSlug}/entitlements: + /licenses/{licenseSlug}/conversion-rules: get: - description: Returns a cursor-paginated page of entitlements granted by a license, identified by its unique slug. - operationId: get-license-entitlements + operationId: listConversionRules parameters: - - description: License slug - in: path + - in: path name: licenseSlug required: true schema: - description: License slug - examples: - - license-slug type: string - - description: Opaque pagination cursor from a previous response's nextCursor - explode: false + - explode: false in: query - name: cursor + name: status schema: - description: Opaque pagination cursor from a previous response's nextCursor + default: ACTIVE + enum: + - ACTIVE + - ARCHIVED type: string - - description: Maximum number of entries to return (default 50, max 200) - explode: false + - explode: false in: query - name: limit + name: entitlementSlug schema: - description: Maximum number of entries to return (default 50, max 200) - format: int32 - maximum: 200 - minimum: 1 - type: integer + type: string + - explode: false + in: query + name: modelSlug + schema: + type: string responses: "200": content: application/json: schema: - $ref: "#/components/schemas/PageLicenseEntitlement" + items: + $ref: "#/components/schemas/ConversionRule" + type: + - array + - "null" description: OK "400": content: @@ -8160,6 +8915,12 @@ paths: schema: $ref: "#/components/schemas/Problem" description: Not Found + "409": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Conflict "422": content: application/problem+json: @@ -8175,31 +8936,30 @@ paths: security: - bearerAuth: - read:licenses - summary: Get all entitlements for a license + summary: List conversion rules tags: - licenses post: - description: Associate an entitlement with a license by providing the license slug and entitlement details - operationId: associate-entitlement-with-license + operationId: createConversionRule parameters: - - description: License slug - in: path + - in: path name: licenseSlug required: true schema: - description: License slug - examples: - - license-slug type: string requestBody: content: application/json: schema: - $ref: "#/components/schemas/LicenseEntitlement" + $ref: "#/components/schemas/CreateConversionRuleBody" required: true responses: - "204": - description: No Content + "201": + content: + application/json: + schema: + $ref: "#/components/schemas/ConversionRule" + description: Created "400": content: application/problem+json: @@ -8242,22 +9002,15 @@ paths: schema: $ref: "#/components/schemas/Problem" description: Internal Server Error - "503": - content: - application/problem+json: - schema: - $ref: "#/components/schemas/Problem" - description: Service Unavailable security: - bearerAuth: - write:licenses - summary: Associate an entitlement with a license + summary: Create a conversion rule tags: - licenses - /licenses/{licenseSlug}/entitlements/{entitlementSlug}: + /licenses/{licenseSlug}/conversion-rules/{ruleId}: delete: - description: Delete a license entitlement with the provided details - operationId: delete-license-entitlement + operationId: archiveConversionRule parameters: - in: path name: licenseSlug @@ -8265,7 +9018,7 @@ paths: schema: type: string - in: path - name: entitlementSlug + name: ruleId required: true schema: type: string @@ -8296,6 +9049,12 @@ paths: schema: $ref: "#/components/schemas/Problem" description: Not Found + "409": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Conflict "422": content: application/problem+json: @@ -8311,12 +9070,11 @@ paths: security: - bearerAuth: - write:licenses - summary: Delete a license entitlement + summary: Archive a conversion rule tags: - licenses get: - description: Retrieve a entitlement for a license by their unique slugs - operationId: get-license-entitlement + operationId: getConversionRule parameters: - in: path name: licenseSlug @@ -8324,7 +9082,7 @@ paths: schema: type: string - in: path - name: entitlementSlug + name: ruleId required: true schema: type: string @@ -8333,8 +9091,14 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/LicenseEntitlement" + $ref: "#/components/schemas/ConversionRule" description: OK + "400": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Bad Request "401": content: application/problem+json: @@ -8353,6 +9117,12 @@ paths: schema: $ref: "#/components/schemas/Problem" description: Not Found + "409": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Conflict "422": content: application/problem+json: @@ -8368,12 +9138,11 @@ paths: security: - bearerAuth: - read:licenses - summary: Get a license entitlement + summary: Get a conversion rule tags: - licenses put: - description: Update an license entitlement with the provided details - operationId: update-license-entitlement + operationId: updateConversionRule parameters: - in: path name: licenseSlug @@ -8381,7 +9150,7 @@ paths: schema: type: string - in: path - name: entitlementSlug + name: ruleId required: true schema: type: string @@ -8389,11 +9158,15 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/LicenseEntitlement" + $ref: "#/components/schemas/UpdateConversionRuleBody" required: true responses: - "204": - description: No Content + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/ConversionRule" + description: OK "400": content: application/problem+json: @@ -8418,6 +9191,12 @@ paths: schema: $ref: "#/components/schemas/Problem" description: Not Found + "409": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Conflict "422": content: application/problem+json: @@ -8433,29 +9212,46 @@ paths: security: - bearerAuth: - write:licenses - summary: Update an license entitlement + summary: Update a conversion rule tags: - licenses - /licenses/{licenseSlug}/publish: - post: - description: "Puts a DRAFT license version on sale: it becomes PUBLISHED, so it can be made its family's default, and its family resolves to it when the family has no default and this is its highest published version. Only a draft can be published (409 PublishLicense.NotADraft); an archived version goes back on sale with unarchive-license. Emits LICENSE_PUBLISHED." - operationId: publish-license + /licenses/{licenseSlug}/entitlements: + get: + description: Returns a cursor-paginated page of entitlements granted by a license, identified by its unique slug. + operationId: get-license-entitlements parameters: - - description: License version slug + - description: License slug in: path name: licenseSlug required: true schema: - description: License version slug + description: License slug examples: - - enterprise-license-v2 + - license-slug + type: string + - description: Opaque pagination cursor from a previous response's nextCursor + explode: false + in: query + name: cursor + schema: + description: Opaque pagination cursor from a previous response's nextCursor type: string + - description: Maximum number of entries to return (default 50, max 200) + explode: false + in: query + name: limit + schema: + description: Maximum number of entries to return (default 50, max 200) + format: int32 + maximum: 200 + minimum: 1 + type: integer responses: "200": content: application/json: schema: - $ref: "#/components/schemas/License" + $ref: "#/components/schemas/PageLicenseEntitlement" description: OK "400": content: @@ -8481,12 +9277,6 @@ paths: schema: $ref: "#/components/schemas/Problem" description: Not Found - "409": - content: - application/problem+json: - schema: - $ref: "#/components/schemas/Problem" - description: Conflict "422": content: application/problem+json: @@ -8501,31 +9291,32 @@ paths: description: Internal Server Error security: - bearerAuth: - - write:licenses - summary: Publish a license version + - read:licenses + summary: Get all entitlements for a license tags: - licenses - /licenses/{licenseSlug}/unarchive: post: - description: "Puts an ARCHIVED license version back on sale: it is PUBLISHED again, can be assigned to instances, and can be what its family resolves to. Only an archived version can be unarchived (409 UnarchiveLicense.NotArchived). Emits LICENSE_UNARCHIVED." - operationId: unarchive-license + description: Associate an entitlement with a license by providing the license slug and entitlement details + operationId: associate-entitlement-with-license parameters: - - description: License version slug + - description: License slug in: path name: licenseSlug required: true schema: - description: License version slug + description: License slug examples: - - enterprise-license-v2 + - license-slug type: string + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/LicenseEntitlement" + required: true responses: - "200": - content: - application/json: - schema: - $ref: "#/components/schemas/License" - description: OK + "204": + description: No Content "400": content: application/problem+json: @@ -8568,50 +9359,36 @@ paths: schema: $ref: "#/components/schemas/Problem" description: Internal Server Error + "503": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Service Unavailable security: - bearerAuth: - write:licenses - summary: Unarchive a license version + summary: Associate an entitlement with a license tags: - licenses - /metadata-fields: - get: - description: Returns a cursor-paginated page of the active (non-archived) metadata fields declared for the org and the given resource type, ordered by displayOrder then createdAt. - operationId: list-metadata-fields + /licenses/{licenseSlug}/entitlements/{entitlementSlug}: + delete: + description: Delete a license entitlement with the provided details + operationId: delete-license-entitlement parameters: - - explode: false - in: query - name: resourceType + - in: path + name: licenseSlug required: true schema: - enum: - - DEPLOYMENT_ZONE - - INSTANCE type: string - - description: Opaque pagination cursor from a previous response's nextCursor - explode: false - in: query - name: cursor + - in: path + name: entitlementSlug + required: true schema: - description: Opaque pagination cursor from a previous response's nextCursor type: string - - description: Maximum number of entries to return (default 50, max 200) - explode: false - in: query - name: limit - schema: - description: Maximum number of entries to return (default 50, max 200) - format: int32 - maximum: 200 - minimum: 1 - type: integer responses: - "200": - content: - application/json: - schema: - $ref: "#/components/schemas/PageMetadataField" - description: OK + "204": + description: No Content "400": content: application/problem+json: @@ -8630,6 +9407,12 @@ paths: schema: $ref: "#/components/schemas/Problem" description: Forbidden + "404": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Not Found "422": content: application/problem+json: @@ -8644,32 +9427,31 @@ paths: description: Internal Server Error security: - bearerAuth: - - read:metadata_fields - summary: List active metadata fields for a resource type + - write:licenses + summary: Delete a license entitlement tags: - - metadataFields - post: - description: Declares a typed metadata field for a given resource type (DEPLOYMENT_ZONE, INSTANCE). The json_schema is validated against JSON Schema 2020-12 before insertion. Restricted to write:metadata_fields scope (admin-only policy). - operationId: create-metadata-field - requestBody: - content: - application/json: - schema: - $ref: "#/components/schemas/MetadataField" - required: true + - licenses + get: + description: Retrieve a entitlement for a license by their unique slugs + operationId: get-license-entitlement + parameters: + - in: path + name: licenseSlug + required: true + schema: + type: string + - in: path + name: entitlementSlug + required: true + schema: + type: string responses: - "201": + "200": content: application/json: schema: - $ref: "#/components/schemas/MetadataField" - description: Created - "400": - content: - application/problem+json: - schema: - $ref: "#/components/schemas/Problem" - description: Bad Request + $ref: "#/components/schemas/LicenseEntitlement" + description: OK "401": content: application/problem+json: @@ -8682,12 +9464,12 @@ paths: schema: $ref: "#/components/schemas/Problem" description: Forbidden - "409": + "404": content: application/problem+json: schema: $ref: "#/components/schemas/Problem" - description: Conflict + description: Not Found "422": content: application/problem+json: @@ -8700,27 +9482,31 @@ paths: schema: $ref: "#/components/schemas/Problem" description: Internal Server Error - "503": - content: - application/problem+json: - schema: - $ref: "#/components/schemas/Problem" - description: Service Unavailable security: - bearerAuth: - - write:metadata_fields - summary: Create a metadata field + - read:licenses + summary: Get a license entitlement tags: - - metadataFields - /metadata-fields/reorder: - post: - description: Sets the displayOrder of the listed fields to their position in the array (0-indexed). All ids must belong to the same organization, resource type, and be non-archived; the entire reorder is applied in a single transaction. - operationId: reorder-metadata-fields + - licenses + put: + description: Update an license entitlement with the provided details + operationId: update-license-entitlement + parameters: + - in: path + name: licenseSlug + required: true + schema: + type: string + - in: path + name: entitlementSlug + required: true + schema: + type: string requestBody: content: application/json: schema: - $ref: "#/components/schemas/ReorderMetadataFieldsInput" + $ref: "#/components/schemas/LicenseEntitlement" required: true responses: "204": @@ -8763,32 +9549,30 @@ paths: description: Internal Server Error security: - bearerAuth: - - write:metadata_fields - summary: Reorder metadata fields + - write:licenses + summary: Update an license entitlement tags: - - metadataFields - /metadata-fields/{id}: - patch: - description: "Updates label and json_schema. `key` and `resourceType` are immutable, and `displayOrder` belongs to POST /metadata-fields/reorder. The new json_schema is validated against JSON Schema 2020-12 and the transition rules: type changes, cardinality changes, and free-string ↔ enum toggles are rejected with 422." - operationId: update-metadata-field + - licenses + /licenses/{licenseSlug}/publish: + post: + description: "Puts a DRAFT license version on sale: it becomes PUBLISHED, so it can be made its family's default, and its family resolves to it when the family has no default and this is its highest published version. Only a draft can be published (409 PublishLicense.NotADraft); an archived version goes back on sale with unarchive-license. Emits LICENSE_PUBLISHED." + operationId: publish-license parameters: - - in: path - name: id + - description: License version slug + in: path + name: licenseSlug required: true schema: + description: License version slug + examples: + - enterprise-license-v2 type: string - requestBody: - content: - application/json: - schema: - $ref: "#/components/schemas/MetadataField" - required: true responses: "200": content: application/json: schema: - $ref: "#/components/schemas/MetadataField" + $ref: "#/components/schemas/License" description: OK "400": content: @@ -8834,26 +9618,30 @@ paths: description: Internal Server Error security: - bearerAuth: - - write:metadata_fields - summary: Update a metadata field + - write:licenses + summary: Publish a license version tags: - - metadataFields - /metadata-fields/{id}/archive: + - licenses + /licenses/{licenseSlug}/unarchive: post: - description: Soft-deletes a metadata field. The row stays in the DB (its archived_at is set) so historic metadata is still interpretable; the field stops appearing in the active schema composition. - operationId: archive-metadata-field + description: "Puts an ARCHIVED license version back on sale: it is PUBLISHED again, can be assigned to instances, and can be what its family resolves to. Only an archived version can be unarchived (409 UnarchiveLicense.NotArchived). Emits LICENSE_UNARCHIVED." + operationId: unarchive-license parameters: - - in: path - name: id + - description: License version slug + in: path + name: licenseSlug required: true schema: + description: License version slug + examples: + - enterprise-license-v2 type: string responses: "200": content: application/json: schema: - $ref: "#/components/schemas/MetadataField" + $ref: "#/components/schemas/License" description: OK "400": content: @@ -8879,6 +9667,151 @@ paths: schema: $ref: "#/components/schemas/Problem" description: Not Found + "409": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Conflict + "422": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unprocessable Entity + "500": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Internal Server Error + security: + - bearerAuth: + - write:licenses + summary: Unarchive a license version + tags: + - licenses + /metadata-fields: + get: + description: "Returns a cursor-paginated page of the metadata fields declared for the org and the given resource type, ordered by displayOrder then createdAt. Only active fields are listed, unless includeArchived is set: an archived field's id is what unarchive-metadata-field takes." + operationId: list-metadata-fields + parameters: + - explode: false + in: query + name: resourceType + required: true + schema: + enum: + - DEPLOYMENT_ZONE + - INSTANCE + type: string + - description: Include archived fields too, each with its archivedAt + explode: false + in: query + name: includeArchived + schema: + description: Include archived fields too, each with its archivedAt + type: boolean + - description: Opaque pagination cursor from a previous response's nextCursor + explode: false + in: query + name: cursor + schema: + description: Opaque pagination cursor from a previous response's nextCursor + type: string + - description: Maximum number of entries to return (default 50, max 200) + explode: false + in: query + name: limit + schema: + description: Maximum number of entries to return (default 50, max 200) + format: int32 + maximum: 200 + minimum: 1 + type: integer + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/PageMetadataField" + description: OK + "400": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Bad Request + "401": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unauthorized + "403": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Forbidden + "422": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unprocessable Entity + "500": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Internal Server Error + security: + - bearerAuth: + - read:metadata_fields + summary: List metadata fields for a resource type + tags: + - metadataFields + post: + description: Declares a typed metadata field for a given resource type (DEPLOYMENT_ZONE, INSTANCE). The json_schema is validated against JSON Schema 2020-12 before insertion. Restricted to write:metadata_fields scope (admin-only policy). + operationId: create-metadata-field + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/MetadataField" + required: true + responses: + "201": + content: + application/json: + schema: + $ref: "#/components/schemas/MetadataField" + description: Created + "400": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Bad Request + "401": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unauthorized + "403": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Forbidden + "409": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Conflict "422": content: application/problem+json: @@ -8891,16 +9824,77 @@ paths: schema: $ref: "#/components/schemas/Problem" description: Internal Server Error + "503": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Service Unavailable security: - bearerAuth: - write:metadata_fields - summary: Archive a metadata field + summary: Create a metadata field tags: - metadataFields - /metadata-fields/{id}/dry-run: + /metadata-fields/reorder: post: - description: Computes, server-side, how many existing resources of this field's resource type carry a value under this field's key that would NOT satisfy the supplied candidate JSON Schema, plus a small sample. Read-only — does not mutate the field. Replaces the former client-side dry-run that pulled every resource's metadata. - operationId: dry-run-metadata-field + description: Sets the displayOrder of the listed fields to their position in the array (0-indexed). All ids must belong to the same organization, resource type, and be non-archived; the entire reorder is applied in a single transaction. + operationId: reorder-metadata-fields + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/ReorderMetadataFieldsInput" + required: true + responses: + "204": + description: No Content + "400": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Bad Request + "401": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unauthorized + "403": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Forbidden + "404": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Not Found + "422": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unprocessable Entity + "500": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Internal Server Error + security: + - bearerAuth: + - write:metadata_fields + summary: Reorder metadata fields + tags: + - metadataFields + /metadata-fields/{id}: + patch: + description: "Updates label and json_schema. `key` and `resourceType` are immutable, and `displayOrder` belongs to POST /metadata-fields/reorder. The new json_schema is validated against JSON Schema 2020-12 and the transition rules: type changes, cardinality changes, and free-string ↔ enum toggles are rejected with 422." + operationId: update-metadata-field parameters: - in: path name: id @@ -8911,14 +9905,14 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/MetadataFieldSchemaProposal" + $ref: "#/components/schemas/MetadataField" required: true responses: "200": content: application/json: schema: - $ref: "#/components/schemas/Impact" + $ref: "#/components/schemas/MetadataField" description: OK "400": content: @@ -8944,6 +9938,12 @@ paths: schema: $ref: "#/components/schemas/Problem" description: Not Found + "409": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Conflict "422": content: application/problem+json: @@ -8959,32 +9959,463 @@ paths: security: - bearerAuth: - write:metadata_fields - summary: Preview the impact of a metadata field schema change + summary: Update a metadata field tags: - metadataFields - /metadata-fields/{id}/unarchive: + /metadata-fields/{id}/archive: post: - description: Restores a previously archived metadata field (clears its archived_at). The field re-enters the active schema composition. Returns 409 if an active field already uses the same key for this resource type, or 404 if the field does not exist or is not archived. - operationId: unarchive-metadata-field + description: Soft-deletes a metadata field. The row stays in the DB (its archived_at is set) so historic metadata is still interpretable; the field stops appearing in the active schema composition. + operationId: archive-metadata-field + parameters: + - in: path + name: id + required: true + schema: + type: string + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/MetadataField" + description: OK + "400": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Bad Request + "401": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unauthorized + "403": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Forbidden + "404": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Not Found + "422": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unprocessable Entity + "500": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Internal Server Error + security: + - bearerAuth: + - write:metadata_fields + summary: Archive a metadata field + tags: + - metadataFields + /metadata-fields/{id}/dry-run: + post: + description: Computes, server-side, how many existing resources of this field's resource type carry a value under this field's key that would NOT satisfy the supplied candidate JSON Schema, plus a small sample. Read-only — does not mutate the field. Replaces the former client-side dry-run that pulled every resource's metadata. + operationId: dry-run-metadata-field + parameters: + - in: path + name: id + required: true + schema: + type: string + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/MetadataFieldSchemaProposal" + required: true + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/Impact" + description: OK + "400": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Bad Request + "401": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unauthorized + "403": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Forbidden + "404": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Not Found + "422": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unprocessable Entity + "500": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Internal Server Error + security: + - bearerAuth: + - write:metadata_fields + summary: Preview the impact of a metadata field schema change + tags: + - metadataFields + /metadata-fields/{id}/unarchive: + post: + description: Restores a previously archived metadata field (clears its archived_at). The field re-enters the active schema composition. Returns 409 if an active field already uses the same key for this resource type, or 404 if the field does not exist or is not archived. + operationId: unarchive-metadata-field + parameters: + - in: path + name: id + required: true + schema: + type: string + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/MetadataField" + description: OK + "400": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Bad Request + "401": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unauthorized + "403": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Forbidden + "404": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Not Found + "409": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Conflict + "422": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unprocessable Entity + "500": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Internal Server Error + security: + - bearerAuth: + - write:metadata_fields + summary: Unarchive a metadata field + tags: + - metadataFields + /models: + get: + operationId: listModels + parameters: + - explode: false + in: query + name: cursor + schema: + type: string + - description: Page size (default 50, max 200) + explode: false + in: query + name: limit + schema: + description: Page size (default 50, max 200) + format: int32 + maximum: 200 + minimum: 1 + type: integer + - explode: false + in: query + name: status + schema: + enum: + - ACTIVE + - DEPRECATED + - ARCHIVED + type: string + - explode: false + in: query + name: vendor + schema: + type: string + - explode: false + in: query + name: modality + schema: + enum: + - TEXT + - IMAGE + - AUDIO + - VIDEO + - EMBEDDING + - MULTIMODAL + type: string + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/PageModel" + description: OK + "400": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Bad Request + "401": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unauthorized + "403": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Forbidden + "422": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unprocessable Entity + "500": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Internal Server Error + security: + - bearerAuth: + - read:models + summary: List models + tags: + - models + post: + operationId: createModel + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/Model" + required: true + responses: + "201": + content: + application/json: + schema: + $ref: "#/components/schemas/Model" + description: Created + "401": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unauthorized + "403": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Forbidden + "409": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Conflict + "422": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unprocessable Entity + "500": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Internal Server Error + security: + - bearerAuth: + - write:models + summary: Create a model + tags: + - models + /models/{modelSlug}: + delete: + operationId: deleteModel + parameters: + - in: path + name: modelSlug + required: true + schema: + type: string + responses: + "204": + description: No Content + "401": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unauthorized + "403": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Forbidden + "404": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Not Found + "409": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Conflict + "422": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unprocessable Entity + "500": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Internal Server Error + security: + - bearerAuth: + - write:models + summary: Delete a model + tags: + - models + get: + operationId: getModel + parameters: + - in: path + name: modelSlug + required: true + schema: + type: string + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/Model" + description: OK + "401": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unauthorized + "403": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Forbidden + "404": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Not Found + "422": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unprocessable Entity + "500": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Internal Server Error + security: + - bearerAuth: + - read:models + summary: Get a model + tags: + - models + put: + operationId: updateModel parameters: - in: path - name: id + name: modelSlug required: true schema: type: string + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/Model" + required: true responses: "200": content: application/json: schema: - $ref: "#/components/schemas/MetadataField" + $ref: "#/components/schemas/Model" description: OK - "400": - content: - application/problem+json: - schema: - $ref: "#/components/schemas/Problem" - description: Bad Request "401": content: application/problem+json: @@ -9023,10 +10454,10 @@ paths: description: Internal Server Error security: - bearerAuth: - - write:metadata_fields - summary: Unarchive a metadata field + - write:models + summary: Update a model tags: - - metadataFields + - models /ofrep/v1/evaluate/flags: post: description: |- @@ -9941,7 +11372,7 @@ paths: - service-accounts /v1/notification-preferences: get: - description: "Every notifiable event, with this user's effective value per channel: their own choice where they made one, the catalogue default otherwise. Events absent from the catalogue of a deployment never appear, which is what keeps high-volume system events out of the feed." + description: "Every notifiable event, with this user's effective value per channel: their own choice where they made one, the catalog default otherwise. Events absent from the catalog of an installation never appear, which is what keeps high-volume system events out of the feed." operationId: getNotificationPreferences responses: "200": @@ -9975,7 +11406,7 @@ paths: tags: - notifications put: - description: Stores the signed-in user's choices and answers with the whole matrix. A value that equals the catalogue default is stored as no row at all, so a later change to that default still reaches this user. + description: Stores the signed-in user's choices and answers with the whole matrix. A value that equals the catalog default is stored as no row at all, so a later change to that default still reaches this user. operationId: putNotificationPreferences requestBody: content: @@ -10190,6 +11621,8 @@ webhooks: properties: data: $ref: "#/components/schemas/Component" + headers: + $ref: "#/components/schemas/EventHeaders" name: description: Event name enum: @@ -10229,6 +11662,8 @@ webhooks: properties: data: $ref: "#/components/schemas/Component" + headers: + $ref: "#/components/schemas/EventHeaders" name: description: Event name enum: @@ -10268,6 +11703,8 @@ webhooks: properties: data: $ref: "#/components/schemas/Component" + headers: + $ref: "#/components/schemas/EventHeaders" name: description: Event name enum: @@ -10307,6 +11744,8 @@ webhooks: properties: data: $ref: "#/components/schemas/Customer" + headers: + $ref: "#/components/schemas/EventHeaders" name: description: Event name enum: @@ -10346,6 +11785,8 @@ webhooks: properties: data: $ref: "#/components/schemas/CustomerCreationRejected" + headers: + $ref: "#/components/schemas/EventHeaders" name: description: Event name enum: @@ -10385,6 +11826,8 @@ webhooks: properties: data: $ref: "#/components/schemas/Customer" + headers: + $ref: "#/components/schemas/EventHeaders" name: description: Event name enum: @@ -10424,6 +11867,8 @@ webhooks: properties: data: $ref: "#/components/schemas/Customer" + headers: + $ref: "#/components/schemas/EventHeaders" name: description: Event name enum: @@ -10463,6 +11908,8 @@ webhooks: properties: data: $ref: "#/components/schemas/DeploymentZone" + headers: + $ref: "#/components/schemas/EventHeaders" name: description: Event name enum: @@ -10502,6 +11949,8 @@ webhooks: properties: data: $ref: "#/components/schemas/DeploymentZone" + headers: + $ref: "#/components/schemas/EventHeaders" name: description: Event name enum: @@ -10541,6 +11990,8 @@ webhooks: properties: data: $ref: "#/components/schemas/Deployment" + headers: + $ref: "#/components/schemas/EventHeaders" name: description: Event name enum: @@ -10580,6 +12031,8 @@ webhooks: properties: data: $ref: "#/components/schemas/DeploymentZone" + headers: + $ref: "#/components/schemas/EventHeaders" name: description: Event name enum: @@ -10619,6 +12072,8 @@ webhooks: properties: data: $ref: "#/components/schemas/Entitlement" + headers: + $ref: "#/components/schemas/EventHeaders" name: description: Event name enum: @@ -10658,6 +12113,8 @@ webhooks: properties: data: $ref: "#/components/schemas/Entitlement" + headers: + $ref: "#/components/schemas/EventHeaders" name: description: Event name enum: @@ -10697,6 +12154,8 @@ webhooks: properties: data: $ref: "#/components/schemas/Entitlement" + headers: + $ref: "#/components/schemas/EventHeaders" name: description: Event name enum: @@ -10736,6 +12195,8 @@ webhooks: properties: data: $ref: "#/components/schemas/EntitlementGroup" + headers: + $ref: "#/components/schemas/EventHeaders" name: description: Event name enum: @@ -10775,6 +12236,8 @@ webhooks: properties: data: $ref: "#/components/schemas/EntitlementGroup" + headers: + $ref: "#/components/schemas/EventHeaders" name: description: Event name enum: @@ -10814,6 +12277,8 @@ webhooks: properties: data: $ref: "#/components/schemas/EntitlementGroup" + headers: + $ref: "#/components/schemas/EventHeaders" name: description: Event name enum: @@ -10853,6 +12318,8 @@ webhooks: properties: data: $ref: "#/components/schemas/FeatureFlag" + headers: + $ref: "#/components/schemas/EventHeaders" name: description: Event name enum: @@ -10892,6 +12359,8 @@ webhooks: properties: data: $ref: "#/components/schemas/FeatureFlag" + headers: + $ref: "#/components/schemas/EventHeaders" name: description: Event name enum: @@ -10931,6 +12400,8 @@ webhooks: properties: data: $ref: "#/components/schemas/FeatureFlagEvaluated" + headers: + $ref: "#/components/schemas/EventHeaders" name: description: Event name enum: @@ -10970,6 +12441,8 @@ webhooks: properties: data: $ref: "#/components/schemas/FeatureFlag" + headers: + $ref: "#/components/schemas/EventHeaders" name: description: Event name enum: @@ -10999,7 +12472,7 @@ webhooks: - featureflags com.kaiten.identity.v1.system_token_issued: post: - description: "Triggered when a platform credential mints an organization-scoped credential for the `system:kaiten` platform identity inside this organization. Carries the platform credential that performed the mint and the scopes granted, never the credential value. Expect this at the rate an integration refreshes: roughly 120 per day per process at a 15-minute lifetime." + description: "Triggered when a platform credential mints an organization-scoped credential for the `system:kaiten` platform identity inside this organization. Carries the platform credential that performed the mint and the scopes granted, never the credential value. Expect this at the rate an integration refreshes: 96 a day per process at a 15-minute lifetime, more if it refreshes before expiry." operationId: onSystemOrganizationTokenIssued requestBody: content: @@ -11009,6 +12482,8 @@ webhooks: properties: data: $ref: "#/components/schemas/SystemTokenIssuance" + headers: + $ref: "#/components/schemas/EventHeaders" name: description: Event name enum: @@ -11036,6 +12511,47 @@ webhooks: tags: - webhooks - identity + com.kaiten.instance.ai.v1.model_usage_unauthorized: + post: + description: AI usage was recorded without an authorized model conversion rule. + operationId: onInstanceAIModelUsageUnauthorized + requestBody: + content: + application/json: + schema: + additionalProperties: false + properties: + data: + $ref: "#/components/schemas/InstanceAIModelUsageUnauthorized" + headers: + $ref: "#/components/schemas/EventHeaders" + name: + description: Event name + enum: + - INSTANCE_AI_MODEL_USAGE_UNAUTHORIZED + examples: + - INSTANCE_AI_MODEL_USAGE_UNAUTHORIZED + type: string + type: + description: Event type + enum: + - com.kaiten.instance.ai.v1.model_usage_unauthorized + examples: + - com.kaiten.instance.ai.v1.model_usage_unauthorized + type: string + required: + - name + - type + - data + type: object + required: true + responses: + "200": + description: Webhook processed successfully + summary: Unauthorized model usage + tags: + - webhooks + - instances com.kaiten.instance.entitlement.v1.cap_exceeded: post: description: Triggered when a SOFT-enforcement entitlement's usage crosses from below the cap to above it. Fires once per crossing. @@ -11048,6 +12564,8 @@ webhooks: properties: data: $ref: "#/components/schemas/InstanceEntitlementCapExceeded" + headers: + $ref: "#/components/schemas/EventHeaders" name: description: Event name enum: @@ -11075,6 +12593,47 @@ webhooks: tags: - webhooks - instances + com.kaiten.instance.entitlement.v1.maximum_exceeded: + post: + description: A committed AI usage charge crossed a soft pool's maximum. + operationId: onInstanceEntitlementMaximumExceeded + requestBody: + content: + application/json: + schema: + additionalProperties: false + properties: + data: + $ref: "#/components/schemas/InstanceEntitlementCapExceeded" + headers: + $ref: "#/components/schemas/EventHeaders" + name: + description: Event name + enum: + - INSTANCE_ENTITLEMENT_MAXIMUM_EXCEEDED + examples: + - INSTANCE_ENTITLEMENT_MAXIMUM_EXCEEDED + type: string + type: + description: Event type + enum: + - com.kaiten.instance.entitlement.v1.maximum_exceeded + examples: + - com.kaiten.instance.entitlement.v1.maximum_exceeded + type: string + required: + - name + - type + - data + type: object + required: true + responses: + "200": + description: Webhook processed successfully + summary: Instance entitlement maximum exceeded + tags: + - webhooks + - instances com.kaiten.instance.entitlement.v1.usage_period_rolled_over: post: description: "Triggered once per usage window closed by a report: the entitlement's own stored bucket, and one synthetic event per window skipped entirely since the last report." @@ -11087,6 +12646,8 @@ webhooks: properties: data: $ref: "#/components/schemas/InstanceEntitlementUsagePeriodRolledOver" + headers: + $ref: "#/components/schemas/EventHeaders" name: description: Event name enum: @@ -11126,6 +12687,8 @@ webhooks: properties: data: $ref: "#/components/schemas/EntitlementUsage" + headers: + $ref: "#/components/schemas/EventHeaders" name: description: Event name enum: @@ -11165,6 +12728,8 @@ webhooks: properties: data: $ref: "#/components/schemas/InstanceEntitlementUsageReportAccepted" + headers: + $ref: "#/components/schemas/EventHeaders" name: description: Event name enum: @@ -11204,6 +12769,8 @@ webhooks: properties: data: $ref: "#/components/schemas/InstanceEntitlementUsageReportAccepted" + headers: + $ref: "#/components/schemas/EventHeaders" name: description: Event name enum: @@ -11243,6 +12810,8 @@ webhooks: properties: data: $ref: "#/components/schemas/InstanceEntitlementUsageWarningThresholdReached" + headers: + $ref: "#/components/schemas/EventHeaders" name: description: Event name enum: @@ -11282,6 +12851,8 @@ webhooks: properties: data: $ref: "#/components/schemas/InstanceEntitlementValueGet" + headers: + $ref: "#/components/schemas/EventHeaders" name: description: Event name enum: @@ -11321,6 +12892,8 @@ webhooks: properties: data: $ref: "#/components/schemas/Instance" + headers: + $ref: "#/components/schemas/EventHeaders" name: description: Event name enum: @@ -11360,6 +12933,8 @@ webhooks: properties: data: $ref: "#/components/schemas/Instance" + headers: + $ref: "#/components/schemas/EventHeaders" name: description: Event name enum: @@ -11399,6 +12974,8 @@ webhooks: properties: data: $ref: "#/components/schemas/Instance" + headers: + $ref: "#/components/schemas/EventHeaders" name: description: Event name enum: @@ -11438,6 +13015,8 @@ webhooks: properties: data: $ref: "#/components/schemas/InstanceLifecycleStageChanged" + headers: + $ref: "#/components/schemas/EventHeaders" name: description: Event name enum: @@ -11477,6 +13056,8 @@ webhooks: properties: data: $ref: "#/components/schemas/Instance" + headers: + $ref: "#/components/schemas/EventHeaders" name: description: Event name enum: @@ -11516,6 +13097,8 @@ webhooks: properties: data: $ref: "#/components/schemas/InstanceStatusChanged" + headers: + $ref: "#/components/schemas/EventHeaders" name: description: Event name enum: @@ -11555,6 +13138,8 @@ webhooks: properties: data: $ref: "#/components/schemas/Instance" + headers: + $ref: "#/components/schemas/EventHeaders" name: description: Event name enum: @@ -11582,6 +13167,129 @@ webhooks: tags: - webhooks - instances + com.kaiten.license.conversion_rule.v1.archived: + post: + description: Conversion rule change. + operationId: onArchiveConversionRule + requestBody: + content: + application/json: + schema: + additionalProperties: false + properties: + data: + $ref: "#/components/schemas/ConversionRule" + headers: + $ref: "#/components/schemas/EventHeaders" + name: + description: Event name + enum: + - LICENSE_CONVERSION_RULE_ARCHIVED + examples: + - LICENSE_CONVERSION_RULE_ARCHIVED + type: string + type: + description: Event type + enum: + - com.kaiten.license.conversion_rule.v1.archived + examples: + - com.kaiten.license.conversion_rule.v1.archived + type: string + required: + - name + - type + - data + type: object + required: true + responses: + "200": + description: Webhook processed successfully + summary: Archive a conversion rule webhook + tags: + - webhooks + - licenses + com.kaiten.license.conversion_rule.v1.created: + post: + description: Conversion rule change. + operationId: onCreateConversionRule + requestBody: + content: + application/json: + schema: + additionalProperties: false + properties: + data: + $ref: "#/components/schemas/ConversionRule" + headers: + $ref: "#/components/schemas/EventHeaders" + name: + description: Event name + enum: + - LICENSE_CONVERSION_RULE_CREATED + examples: + - LICENSE_CONVERSION_RULE_CREATED + type: string + type: + description: Event type + enum: + - com.kaiten.license.conversion_rule.v1.created + examples: + - com.kaiten.license.conversion_rule.v1.created + type: string + required: + - name + - type + - data + type: object + required: true + responses: + "200": + description: Webhook processed successfully + summary: Create a conversion rule webhook + tags: + - webhooks + - licenses + com.kaiten.license.conversion_rule.v1.updated: + post: + description: Conversion rule change. + operationId: onUpdateConversionRule + requestBody: + content: + application/json: + schema: + additionalProperties: false + properties: + data: + $ref: "#/components/schemas/ConversionRuleUpdated" + headers: + $ref: "#/components/schemas/EventHeaders" + name: + description: Event name + enum: + - LICENSE_CONVERSION_RULE_UPDATED + examples: + - LICENSE_CONVERSION_RULE_UPDATED + type: string + type: + description: Event type + enum: + - com.kaiten.license.conversion_rule.v1.updated + examples: + - com.kaiten.license.conversion_rule.v1.updated + type: string + required: + - name + - type + - data + type: object + required: true + responses: + "200": + description: Webhook processed successfully + summary: Update a conversion rule webhook + tags: + - webhooks + - licenses com.kaiten.license.entitlement.v1.assigned: post: description: Triggered when an entitlement is assigned to a license. @@ -11594,6 +13302,8 @@ webhooks: properties: data: $ref: "#/components/schemas/LicenseEntitlement" + headers: + $ref: "#/components/schemas/EventHeaders" name: description: Event name enum: @@ -11633,6 +13343,8 @@ webhooks: properties: data: $ref: "#/components/schemas/LicenseEntitlement" + headers: + $ref: "#/components/schemas/EventHeaders" name: description: Event name enum: @@ -11672,6 +13384,8 @@ webhooks: properties: data: $ref: "#/components/schemas/LicenseEntitlement" + headers: + $ref: "#/components/schemas/EventHeaders" name: description: Event name enum: @@ -11711,6 +13425,8 @@ webhooks: properties: data: $ref: "#/components/schemas/License" + headers: + $ref: "#/components/schemas/EventHeaders" name: description: Event name enum: @@ -11750,6 +13466,8 @@ webhooks: properties: data: $ref: "#/components/schemas/License" + headers: + $ref: "#/components/schemas/EventHeaders" name: description: Event name enum: @@ -11789,6 +13507,8 @@ webhooks: properties: data: $ref: "#/components/schemas/License" + headers: + $ref: "#/components/schemas/EventHeaders" name: description: Event name enum: @@ -11828,6 +13548,8 @@ webhooks: properties: data: $ref: "#/components/schemas/License" + headers: + $ref: "#/components/schemas/EventHeaders" name: description: Event name enum: @@ -11867,6 +13589,8 @@ webhooks: properties: data: $ref: "#/components/schemas/License" + headers: + $ref: "#/components/schemas/EventHeaders" name: description: Event name enum: @@ -11906,6 +13630,8 @@ webhooks: properties: data: $ref: "#/components/schemas/License" + headers: + $ref: "#/components/schemas/EventHeaders" name: description: Event name enum: @@ -11945,6 +13671,8 @@ webhooks: properties: data: $ref: "#/components/schemas/LicenseFamilyView" + headers: + $ref: "#/components/schemas/EventHeaders" name: description: Event name enum: @@ -11984,6 +13712,8 @@ webhooks: properties: data: $ref: "#/components/schemas/LicenseFamilyView" + headers: + $ref: "#/components/schemas/EventHeaders" name: description: Event name enum: @@ -12023,6 +13753,8 @@ webhooks: properties: data: $ref: "#/components/schemas/LicenseFamilyView" + headers: + $ref: "#/components/schemas/EventHeaders" name: description: Event name enum: @@ -12062,6 +13794,8 @@ webhooks: properties: data: $ref: "#/components/schemas/MetadataField" + headers: + $ref: "#/components/schemas/EventHeaders" name: description: Event name enum: @@ -12101,6 +13835,8 @@ webhooks: properties: data: $ref: "#/components/schemas/MetadataField" + headers: + $ref: "#/components/schemas/EventHeaders" name: description: Event name enum: @@ -12140,6 +13876,8 @@ webhooks: properties: data: $ref: "#/components/schemas/MetadataFieldReordered" + headers: + $ref: "#/components/schemas/EventHeaders" name: description: Event name enum: @@ -12179,6 +13917,8 @@ webhooks: properties: data: $ref: "#/components/schemas/MetadataField" + headers: + $ref: "#/components/schemas/EventHeaders" name: description: Event name enum: @@ -12218,6 +13958,8 @@ webhooks: properties: data: $ref: "#/components/schemas/MetadataField" + headers: + $ref: "#/components/schemas/EventHeaders" name: description: Event name enum: @@ -12245,6 +13987,129 @@ webhooks: tags: - webhooks - metadataFields + com.kaiten.model.v1.created: + post: + description: Triggered when a catalogue model is created. + operationId: onModelCreated + requestBody: + content: + application/json: + schema: + additionalProperties: false + properties: + data: + $ref: "#/components/schemas/Model" + headers: + $ref: "#/components/schemas/EventHeaders" + name: + description: Event name + enum: + - MODEL_CREATED + examples: + - MODEL_CREATED + type: string + type: + description: Event type + enum: + - com.kaiten.model.v1.created + examples: + - com.kaiten.model.v1.created + type: string + required: + - name + - type + - data + type: object + required: true + responses: + "200": + description: Webhook processed successfully + summary: Model Created Webhook + tags: + - webhooks + - models + com.kaiten.model.v1.deleted: + post: + description: Triggered when a catalogue model is deleted. + operationId: onModelDeleted + requestBody: + content: + application/json: + schema: + additionalProperties: false + properties: + data: + $ref: "#/components/schemas/Model" + headers: + $ref: "#/components/schemas/EventHeaders" + name: + description: Event name + enum: + - MODEL_DELETED + examples: + - MODEL_DELETED + type: string + type: + description: Event type + enum: + - com.kaiten.model.v1.deleted + examples: + - com.kaiten.model.v1.deleted + type: string + required: + - name + - type + - data + type: object + required: true + responses: + "200": + description: Webhook processed successfully + summary: Model Deleted Webhook + tags: + - webhooks + - models + com.kaiten.model.v1.updated: + post: + description: Triggered when a catalogue model is updated. + operationId: onModelUpdated + requestBody: + content: + application/json: + schema: + additionalProperties: false + properties: + data: + $ref: "#/components/schemas/ModelUpdated" + headers: + $ref: "#/components/schemas/EventHeaders" + name: + description: Event name + enum: + - MODEL_UPDATED + examples: + - MODEL_UPDATED + type: string + type: + description: Event type + enum: + - com.kaiten.model.v1.updated + examples: + - com.kaiten.model.v1.updated + type: string + required: + - name + - type + - data + type: object + required: true + responses: + "200": + description: Webhook processed successfully + summary: Model Updated Webhook + tags: + - webhooks + - models com.kaiten.release.v1.created: post: description: Triggered when a new release is created. @@ -12257,6 +14122,8 @@ webhooks: properties: data: $ref: "#/components/schemas/Release" + headers: + $ref: "#/components/schemas/EventHeaders" name: description: Event name enum: @@ -12296,6 +14163,8 @@ webhooks: properties: data: $ref: "#/components/schemas/Release" + headers: + $ref: "#/components/schemas/EventHeaders" name: description: Event name enum: diff --git a/openapi/platform-openapi.yaml b/openapi/platform-openapi.yaml index 38b05bc..159b0d7 100644 --- a/openapi/platform-openapi.yaml +++ b/openapi/platform-openapi.yaml @@ -365,7 +365,7 @@ openapi: 3.1.0 paths: /platform/connectors: post: - description: Registers or updates connector metadata and settings schema for this Kaiten deployment + description: Registers or updates connector metadata and settings schema for this Kaiten installation operationId: register-connector requestBody: content: @@ -454,7 +454,7 @@ paths: - platform /platform/organizations: post: - description: "Converges on the organization an external identity provider describes, creating it if it is not already there and returning it either way. The internal id is derived from the external id, so the operation is idempotent: a re-run returns the same organization instead of creating a second tenant, and an organization that already exists -- including one provisioned on a first login -- is returned unchanged apart from acquiring a name if it had none. Platform API: requires a Kaiten platform token (`ksm_...`) with write:organizations." + description: "Converges on the organization an external identity provider describes, creating it if it is not already there and returning it either way. The internal id is derived from the external id, so the operation is idempotent: a re-run returns the same organization instead of creating a second one, and an organization that already exists -- including one provisioned on a first login -- is returned unchanged apart from acquiring a name if it had none. Platform API: requires a Kaiten platform token (`ksm_...`) with write:organizations." operationId: ensure-organization requestBody: content: diff --git a/openapi/source.yaml b/openapi/source.yaml index 3f7c144..b8a1bf8 100644 --- a/openapi/source.yaml +++ b/openapi/source.yaml @@ -1,9 +1,9 @@ # Where the two snapshots in this directory were copied from. Rewritten by # `task sync:openapi`; never edit the snapshots themselves by hand. repository: kaitencloud/kaiten -ref: origin/main -commit: fa7f554fb6464f52c2a3f1ed487f4c7383c8125d -synced_at: "2026-09-30" +ref: HEAD +commit: bcdbf36829ceb93b0c2d9a4c1ac3d8d141ce5144 +synced_at: "2026-10-19" files: openapi.yaml: app/openapi.yaml platform-openapi.yaml: app/platform-openapi.yaml diff --git a/scripts/generate_models.py b/scripts/generate_models.py index 441da06..e99d288 100644 --- a/scripts/generate_models.py +++ b/scripts/generate_models.py @@ -72,6 +72,7 @@ # Names for inline unions, keyed by their members. UNION_NAMES: dict[frozenset[str], str] = { + frozenset({"NumberEntitlementValue", "AiUsageValue"}): "ReportUsageValue", frozenset( {"NumberEntitlementValue", "BooleanEntitlementValue", "ConfigEntitlementValue"} ): "EntitlementValue", @@ -82,6 +83,22 @@ # Names for enums with more than one value, keyed by (schema, property). ENUM_NAMES: dict[tuple[str, str], str] = { + ("ReportEntitlementUsageBody", "behavior"): "UsageBehavior", + ("AiInference", "status"): "AiInferenceStatus", + ("AiUsageOutcome", "status"): "AiUsageOutcomeStatus", + ("Model", "status"): "ModelStatus", + ("Model", "modality"): "ModelModality", + ("ConversionRule", "status"): "ConversionRuleStatus", + ("ConversionRule", "chargeOnError"): "AiChargeOnError", + ("AiInference", "requestType"): "AiRequestType", + ("AiUsageValue", "requestType"): "AiRequestType", + ("ConversionRule", "requestType"): "AiRequestType", + ("AiInference", "requestStatus"): "AiRequestStatus", + ("AiUsageValue", "requestStatus"): "AiRequestStatus", + ("AiUsageValue", "phase"): "AiUsagePhase", + ("AiUsageOutcome", "phase"): "AiUsagePhase", + ("AiUsage", "unitType"): "AiUnitType", + ("AiInference", "denialReason"): "AiDenialReason", ("Entitlement", "aggregationMethod"): "AggregationMethod", ("Entitlement", "resetAnchor"): "ResetAnchor", ("Entitlement", "resetPeriod"): "ResetPeriod", @@ -232,6 +249,8 @@ def core_module( ) -> tuple[str, dict[str, str], list[str]]: schemas = spec["components"]["schemas"] roots = response_roots(spec, excluded) | refs_in(spec.get("webhooks") or {}) + # AI reports have a write-only discriminator union; generate it without widening reads. + roots.add("ReportEntitlementUsageBody") emitted = sorted(name for name in closure(schemas, roots) if not PAGE.match(name)) names = {name: CLASS_NAMES.get(name, name) for name in emitted} @@ -494,7 +513,7 @@ def annotation(self, node: dict[str, Any], *, owner: str, prop: str | None) -> s return f"{text} | None" if nullable else text def string(self, node: dict[str, Any], *, owner: str, prop: str | None) -> str: - values = node.get("enum") + values = [value for value in node.get("enum", []) if value is not None] if values: if len(values) == 1: return f"Literal[{json.dumps(values[0])}]" diff --git a/src/kaitencloud/_ai_models.py b/src/kaitencloud/_ai_models.py index 1a88126..b03dec4 100644 --- a/src/kaitencloud/_ai_models.py +++ b/src/kaitencloud/_ai_models.py @@ -1,76 +1,9 @@ # Copyright 2026 KAITEN INC # SPDX-License-Identifier: Apache-2.0 -"""The answer to an AI usage report, written by hand until the contract carries it. +"""Compatibility aliases for the original AI response bridge, now contract-generated.""" -Every other model is generated from ``openapi/openapi.yaml``. These three describe the members -the AI credits contract adds to ``EntitlementUsage`` -- ``reserved`` and ``ai`` -- and are -isolated here so that the regenerated ``EntitlementUsage`` and ``AiUsageOutcome`` replace them -with no change elsewhere: delete this module and import from :mod:`kaitencloud.types`. -""" - -from __future__ import annotations - -from datetime import datetime -from typing import Literal - -from pydantic import Field - -from ._models import KaitenModel -from .types import EntitlementUsage +from .types import AiCredits, AiUsageOutcome +from .types import EntitlementUsage as AiEntitlementUsage __all__ = ["AiCredits", "AiEntitlementUsage", "AiUsageOutcome"] - - -class AiCredits(KaitenModel): - """Credits an AI usage report moved, as decimal strings with at most six decimals.""" - - reserved: str | None = None - """What a reservation set aside, or what a commit closed.""" - - computed: str | None = None - """What the conversion rule priced the call at, before its policy and guardrails.""" - - charged: str | None = None - """What the commit charged the pool.""" - - -class AiUsageOutcome(KaitenModel): - """What an AI usage report did, next to the usage it leaves the pool at.""" - - transaction_id: str = Field(alias="transactionId") - """The report's transaction id, echoed.""" - - phase: Literal["reserve", "commit"] | str - """The phase reported.""" - - status: Literal["RESERVED", "COMMITTED", "RELEASED"] | str - """The operation's status after this report.""" - - credits: AiCredits = Field(default_factory=AiCredits) - """The credits this report reserved, computed or charged.""" - - remaining: str | None = None - """Credits left: the limit, minus usage, minus open reservations. ``None`` when unlimited, - negative when overdrawn.""" - - expires_at: datetime | None = Field(default=None, alias="expiresAt") - """When the reservation lapses. Reservations only.""" - - authorized: bool | None = None - """Commits only: ``False`` when no rule priced the call, or the model is unknown or - archived.""" - - warnings: list[str] = Field(default_factory=list) - """``MODEL_DEPRECATED``, ``ESTIMATE_INCOMPLETE``, ``CONVERSION_INPUT_MISSING``, - ``RESERVATION_EXPIRED``, ``OVERDRAWN``, ``MODEL_NOT_AUTHORIZED``, ``UNKNOWN_MODEL``.""" - - -class AiEntitlementUsage(EntitlementUsage): - """An entitlement's usage after an AI usage report, with what the report did.""" - - reserved: str | None = None - """Credits held by the pool's open, unexpired reservations, as a decimal string.""" - - ai: AiUsageOutcome | None = None - """What the AI usage report did.""" diff --git a/src/kaitencloud/_async/client.py b/src/kaitencloud/_async/client.py index 5b36901..93e0f25 100644 --- a/src/kaitencloud/_async/client.py +++ b/src/kaitencloud/_async/client.py @@ -38,6 +38,7 @@ AsyncLicenseFamilies, AsyncLicenses, AsyncMetadataFields, + AsyncModels, AsyncReleases, AsyncServiceAccounts, ) @@ -222,6 +223,7 @@ def _bind(self) -> None: self.license_families = AsyncLicenseFamilies(api) self.licenses = AsyncLicenses(api) self.metadata_fields = AsyncMetadataFields(api) + self.models = AsyncModels(api) self.releases = AsyncReleases(api) self.service_accounts = AsyncServiceAccounts(api) diff --git a/src/kaitencloud/_async/resources/__init__.py b/src/kaitencloud/_async/resources/__init__.py index 8a40fa4..988aec3 100644 --- a/src/kaitencloud/_async/resources/__init__.py +++ b/src/kaitencloud/_async/resources/__init__.py @@ -13,6 +13,7 @@ from .license_families import AsyncLicenseFamilies from .licenses import AsyncLicenses from .metadata_fields import AsyncMetadataFields +from .models import AsyncModels from .releases import AsyncReleases from .service_accounts import AsyncServiceAccounts @@ -29,6 +30,7 @@ "AsyncLicenseFamilies", "AsyncLicenses", "AsyncMetadataFields", + "AsyncModels", "AsyncReleases", "AsyncServiceAccounts", ] diff --git a/src/kaitencloud/_async/resources/instances.py b/src/kaitencloud/_async/resources/instances.py index 239571c..0719301 100644 --- a/src/kaitencloud/_async/resources/instances.py +++ b/src/kaitencloud/_async/resources/instances.py @@ -27,8 +27,11 @@ optional_datetime, ) from ...types import ( + AiInferencePage, + AiInferenceStatus, AiRequestStatus, AiRequestType, + AiReservations, AiUsageParam, AuditTrail, EntitlementUsage, @@ -465,6 +468,50 @@ async def delete_integration(self, instance_slug: str, integration_name: str) -> "DELETE", api_path("instances", instance_slug, "integrations", integration_name) ) + async def list_ai_inferences( + self, + instance_slug: str, + entitlement_slug: str, + *, + from_: datetime | None = None, + to: datetime | None = None, + status: AiInferenceStatus | None = None, + transaction_id: str | None = None, + cursor: str | None = None, + limit: int | None = None, + ) -> AiInferencePage: + """One newest-first page; pass its next_cursor to fetch the next page.""" + return await self._api.get( + api_path( + "instances", instance_slug, "entitlements", entitlement_slug, "usage", "inferences" + ), + AiInferencePage, + params={ + "from": optional_datetime(from_), + "to": optional_datetime(to), + "status": status, + "transactionId": transaction_id, + "cursor": cursor, + "limit": limit, + }, + ) + + async def list_ai_reservations( + self, instance_slug: str, entitlement_slug: str + ) -> AiReservations: + """The pool's open, unexpired reservations.""" + return await self._api.get( + api_path( + "instances", + instance_slug, + "entitlements", + entitlement_slug, + "usage", + "reservations", + ), + AiReservations, + ) + def _instance( *, diff --git a/src/kaitencloud/_async/resources/licenses.py b/src/kaitencloud/_async/resources/licenses.py index 2e49181..9d42298 100644 --- a/src/kaitencloud/_async/resources/licenses.py +++ b/src/kaitencloud/_async/resources/licenses.py @@ -8,7 +8,16 @@ from ..._types import EntitlementValueInput from ..._utils import api_path, compact, entitlement_value -from ...types import License, LicenseEntitlement, LicenseType +from ...types import ( + AiChargeOnError, + AiRequestType, + ConversionRates, + ConversionRule, + ConversionRuleStatus, + License, + LicenseEntitlement, + LicenseType, +) from ._base import AsyncResource __all__ = ["AsyncLicenses"] @@ -245,3 +254,79 @@ async def delete_entitlement(self, license_slug: str, entitlement_slug: str) -> await self._api.send_empty( "DELETE", api_path("licenses", license_slug, "entitlements", entitlement_slug) ) + + async def list_conversion_rules( + self, + license_slug: str, + *, + status: ConversionRuleStatus = "ACTIVE", + entitlement_slug: str | None = None, + model_slug: str | None = None, + ) -> builtins.list[ConversionRule]: + return await self._api.get_list( + api_path("licenses", license_slug, "conversion-rules"), + ConversionRule, + params={"status": status, "entitlementSlug": entitlement_slug, "modelSlug": model_slug}, + ) + + async def get_conversion_rule(self, license_slug: str, rule_id: str) -> ConversionRule: + return await self._api.get( + api_path("licenses", license_slug, "conversion-rules", rule_id), ConversionRule + ) + + async def create_conversion_rule( + self, + license_slug: str, + *, + entitlement_slug: str, + rates: ConversionRates, + model_slug: str | None = None, + request_type: AiRequestType | None = None, + minimum_credits_per_request: str = "0", + maximum_credits_per_request: str | None = None, + charge_on_error: AiChargeOnError = "NEVER", + ) -> ConversionRule: + body = { + "entitlementSlug": entitlement_slug, + "rates": rates.to_dict(), + "modelSlug": model_slug, + "requestType": request_type, + "minimumCreditsPerRequest": minimum_credits_per_request, + "maximumCreditsPerRequest": maximum_credits_per_request, + "chargeOnError": charge_on_error, + } + return await self._api.send( + "POST", + api_path("licenses", license_slug, "conversion-rules"), + ConversionRule, + body=body, + ) + + async def update_conversion_rule( + self, + license_slug: str, + rule_id: str, + *, + rates: ConversionRates, + minimum_credits_per_request: str = "0", + maximum_credits_per_request: str | None = None, + charge_on_error: AiChargeOnError = "NEVER", + ) -> ConversionRule: + """Replace mutable pricing policy, without re-pointing immutable identity fields.""" + body = { + "rates": rates.to_dict(), + "minimumCreditsPerRequest": minimum_credits_per_request, + "maximumCreditsPerRequest": maximum_credits_per_request, + "chargeOnError": charge_on_error, + } + return await self._api.send( + "PUT", + api_path("licenses", license_slug, "conversion-rules", rule_id), + ConversionRule, + body=body, + ) + + async def archive_conversion_rule(self, license_slug: str, rule_id: str) -> None: + await self._api.request( + "DELETE", api_path("licenses", license_slug, "conversion-rules", rule_id) + ) diff --git a/src/kaitencloud/_async/resources/models.py b/src/kaitencloud/_async/resources/models.py new file mode 100644 index 0000000..f533c78 --- /dev/null +++ b/src/kaitencloud/_async/resources/models.py @@ -0,0 +1,96 @@ +# Copyright 2026 KAITEN INC +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import builtins +from collections.abc import Mapping, Sequence +from typing import Any + +from ..._utils import api_path +from ...types import Model, ModelModality, ModelStatus, VendorCosts +from ._base import AsyncResource + +__all__ = ["AsyncModels"] + + +class AsyncModels(AsyncResource): + """Organization model catalogue; costs are metadata, never SDK pricing.""" + + async def list( + self, + *, + status: ModelStatus | None = None, + vendor: str | None = None, + modality: ModelModality | None = None, + limit: int | None = None, + ) -> builtins.list[Model]: + """Walk catalogue pages, optionally capped at ``limit`` models.""" + return await self._api.list_all( + "/models", + Model, + params={"status": status, "vendor": vendor, "modality": modality}, + limit=limit, + ) + + async def get(self, model_slug: str) -> Model: + return await self._api.get(api_path("models", model_slug), Model) + + async def create( + self, + *, + name: str, + slug: str, + vendor: str, + modality: ModelModality, + status: ModelStatus = "ACTIVE", + description: str = "", + external_ids: Sequence[str] = (), + vendor_costs: VendorCosts | None = None, + metadata: Mapping[str, Any] | None = None, + ) -> Model: + body: dict[str, Any] = { + "name": name, + "slug": slug, + "vendor": vendor, + "modality": modality, + "status": status, + "description": description, + "externalIds": list(external_ids), + "metadata": dict(metadata or {}), + } + if vendor_costs is not None: + body["vendorCosts"] = vendor_costs.to_dict() + return await self._api.send("POST", "/models", Model, body=body) + + async def update( + self, + model_slug: str, + *, + name: str, + vendor: str, + modality: ModelModality, + status: ModelStatus = "ACTIVE", + description: str = "", + external_ids: Sequence[str] = (), + vendor_costs: VendorCosts | None = None, + metadata: Mapping[str, Any] | None = None, + ) -> Model: + """Full replacement; slug and modality must remain unchanged.""" + body: dict[str, Any] = { + "name": name, + "slug": model_slug, + "vendor": vendor, + "modality": modality, + "status": status, + "description": description, + "externalIds": list(external_ids), + "metadata": dict(metadata or {}), + } + if vendor_costs is not None: + body["vendorCosts"] = vendor_costs.to_dict() + return await self._api.send("PUT", api_path("models", model_slug), Model, body=body) + + async def delete(self, model_slug: str) -> None: + """Delete a model; the server refuses models referenced by conversion rules.""" + await self._api.request("DELETE", api_path("models", model_slug)) diff --git a/src/kaitencloud/_sync/client.py b/src/kaitencloud/_sync/client.py index 7699942..8582837 100644 --- a/src/kaitencloud/_sync/client.py +++ b/src/kaitencloud/_sync/client.py @@ -40,6 +40,7 @@ LicenseFamilies, Licenses, MetadataFields, + Models, Releases, ServiceAccounts, ) @@ -224,6 +225,7 @@ def _bind(self) -> None: self.license_families = LicenseFamilies(api) self.licenses = Licenses(api) self.metadata_fields = MetadataFields(api) + self.models = Models(api) self.releases = Releases(api) self.service_accounts = ServiceAccounts(api) diff --git a/src/kaitencloud/_sync/resources/__init__.py b/src/kaitencloud/_sync/resources/__init__.py index ddad12b..6b8b06f 100644 --- a/src/kaitencloud/_sync/resources/__init__.py +++ b/src/kaitencloud/_sync/resources/__init__.py @@ -15,6 +15,7 @@ from .license_families import LicenseFamilies from .licenses import Licenses from .metadata_fields import MetadataFields +from .models import Models from .releases import Releases from .service_accounts import ServiceAccounts @@ -31,6 +32,7 @@ "LicenseFamilies", "Licenses", "MetadataFields", + "Models", "Releases", "ServiceAccounts", ] diff --git a/src/kaitencloud/_sync/resources/instances.py b/src/kaitencloud/_sync/resources/instances.py index 7fa21a2..35394d9 100644 --- a/src/kaitencloud/_sync/resources/instances.py +++ b/src/kaitencloud/_sync/resources/instances.py @@ -29,8 +29,11 @@ optional_datetime, ) from ...types import ( + AiInferencePage, + AiInferenceStatus, AiRequestStatus, AiRequestType, + AiReservations, AiUsageParam, AuditTrail, EntitlementUsage, @@ -465,6 +468,48 @@ def delete_integration(self, instance_slug: str, integration_name: str) -> None: "DELETE", api_path("instances", instance_slug, "integrations", integration_name) ) + def list_ai_inferences( + self, + instance_slug: str, + entitlement_slug: str, + *, + from_: datetime | None = None, + to: datetime | None = None, + status: AiInferenceStatus | None = None, + transaction_id: str | None = None, + cursor: str | None = None, + limit: int | None = None, + ) -> AiInferencePage: + """One newest-first page; pass its next_cursor to fetch the next page.""" + return self._api.get( + api_path( + "instances", instance_slug, "entitlements", entitlement_slug, "usage", "inferences" + ), + AiInferencePage, + params={ + "from": optional_datetime(from_), + "to": optional_datetime(to), + "status": status, + "transactionId": transaction_id, + "cursor": cursor, + "limit": limit, + }, + ) + + def list_ai_reservations(self, instance_slug: str, entitlement_slug: str) -> AiReservations: + """The pool's open, unexpired reservations.""" + return self._api.get( + api_path( + "instances", + instance_slug, + "entitlements", + entitlement_slug, + "usage", + "reservations", + ), + AiReservations, + ) + def _instance( *, diff --git a/src/kaitencloud/_sync/resources/licenses.py b/src/kaitencloud/_sync/resources/licenses.py index ecbc355..49dbcf3 100644 --- a/src/kaitencloud/_sync/resources/licenses.py +++ b/src/kaitencloud/_sync/resources/licenses.py @@ -10,7 +10,16 @@ from ..._types import EntitlementValueInput from ..._utils import api_path, compact, entitlement_value -from ...types import License, LicenseEntitlement, LicenseType +from ...types import ( + AiChargeOnError, + AiRequestType, + ConversionRates, + ConversionRule, + ConversionRuleStatus, + License, + LicenseEntitlement, + LicenseType, +) from ._base import Resource __all__ = ["Licenses"] @@ -243,3 +252,77 @@ def delete_entitlement(self, license_slug: str, entitlement_slug: str) -> None: self._api.send_empty( "DELETE", api_path("licenses", license_slug, "entitlements", entitlement_slug) ) + + def list_conversion_rules( + self, + license_slug: str, + *, + status: ConversionRuleStatus = "ACTIVE", + entitlement_slug: str | None = None, + model_slug: str | None = None, + ) -> builtins.list[ConversionRule]: + return self._api.get_list( + api_path("licenses", license_slug, "conversion-rules"), + ConversionRule, + params={"status": status, "entitlementSlug": entitlement_slug, "modelSlug": model_slug}, + ) + + def get_conversion_rule(self, license_slug: str, rule_id: str) -> ConversionRule: + return self._api.get( + api_path("licenses", license_slug, "conversion-rules", rule_id), ConversionRule + ) + + def create_conversion_rule( + self, + license_slug: str, + *, + entitlement_slug: str, + rates: ConversionRates, + model_slug: str | None = None, + request_type: AiRequestType | None = None, + minimum_credits_per_request: str = "0", + maximum_credits_per_request: str | None = None, + charge_on_error: AiChargeOnError = "NEVER", + ) -> ConversionRule: + body = { + "entitlementSlug": entitlement_slug, + "rates": rates.to_dict(), + "modelSlug": model_slug, + "requestType": request_type, + "minimumCreditsPerRequest": minimum_credits_per_request, + "maximumCreditsPerRequest": maximum_credits_per_request, + "chargeOnError": charge_on_error, + } + return self._api.send( + "POST", + api_path("licenses", license_slug, "conversion-rules"), + ConversionRule, + body=body, + ) + + def update_conversion_rule( + self, + license_slug: str, + rule_id: str, + *, + rates: ConversionRates, + minimum_credits_per_request: str = "0", + maximum_credits_per_request: str | None = None, + charge_on_error: AiChargeOnError = "NEVER", + ) -> ConversionRule: + """Replace mutable pricing policy, without re-pointing immutable identity fields.""" + body = { + "rates": rates.to_dict(), + "minimumCreditsPerRequest": minimum_credits_per_request, + "maximumCreditsPerRequest": maximum_credits_per_request, + "chargeOnError": charge_on_error, + } + return self._api.send( + "PUT", + api_path("licenses", license_slug, "conversion-rules", rule_id), + ConversionRule, + body=body, + ) + + def archive_conversion_rule(self, license_slug: str, rule_id: str) -> None: + self._api.request("DELETE", api_path("licenses", license_slug, "conversion-rules", rule_id)) diff --git a/src/kaitencloud/_sync/resources/models.py b/src/kaitencloud/_sync/resources/models.py new file mode 100644 index 0000000..d8d969d --- /dev/null +++ b/src/kaitencloud/_sync/resources/models.py @@ -0,0 +1,98 @@ +# Copyright 2026 KAITEN INC +# SPDX-License-Identifier: Apache-2.0 + +# Generated by scripts/unasync.py from src/kaitencloud/_async/resources/models.py. Do not edit by hand: +# change the async source, then run `task generate`. +from __future__ import annotations + +import builtins +from collections.abc import Mapping, Sequence +from typing import Any + +from ..._utils import api_path +from ...types import Model, ModelModality, ModelStatus, VendorCosts +from ._base import Resource + +__all__ = ["Models"] + + +class Models(Resource): + """Organization model catalogue; costs are metadata, never SDK pricing.""" + + def list( + self, + *, + status: ModelStatus | None = None, + vendor: str | None = None, + modality: ModelModality | None = None, + limit: int | None = None, + ) -> builtins.list[Model]: + """Walk catalogue pages, optionally capped at ``limit`` models.""" + return self._api.list_all( + "/models", + Model, + params={"status": status, "vendor": vendor, "modality": modality}, + limit=limit, + ) + + def get(self, model_slug: str) -> Model: + return self._api.get(api_path("models", model_slug), Model) + + def create( + self, + *, + name: str, + slug: str, + vendor: str, + modality: ModelModality, + status: ModelStatus = "ACTIVE", + description: str = "", + external_ids: Sequence[str] = (), + vendor_costs: VendorCosts | None = None, + metadata: Mapping[str, Any] | None = None, + ) -> Model: + body: dict[str, Any] = { + "name": name, + "slug": slug, + "vendor": vendor, + "modality": modality, + "status": status, + "description": description, + "externalIds": list(external_ids), + "metadata": dict(metadata or {}), + } + if vendor_costs is not None: + body["vendorCosts"] = vendor_costs.to_dict() + return self._api.send("POST", "/models", Model, body=body) + + def update( + self, + model_slug: str, + *, + name: str, + vendor: str, + modality: ModelModality, + status: ModelStatus = "ACTIVE", + description: str = "", + external_ids: Sequence[str] = (), + vendor_costs: VendorCosts | None = None, + metadata: Mapping[str, Any] | None = None, + ) -> Model: + """Full replacement; slug and modality must remain unchanged.""" + body: dict[str, Any] = { + "name": name, + "slug": model_slug, + "vendor": vendor, + "modality": modality, + "status": status, + "description": description, + "externalIds": list(external_ids), + "metadata": dict(metadata or {}), + } + if vendor_costs is not None: + body["vendorCosts"] = vendor_costs.to_dict() + return self._api.send("PUT", api_path("models", model_slug), Model, body=body) + + def delete(self, model_slug: str) -> None: + """Delete a model; the server refuses models referenced by conversion rules.""" + self._api.request("DELETE", api_path("models", model_slug)) diff --git a/src/kaitencloud/types/__init__.py b/src/kaitencloud/types/__init__.py index bbc5762..f1d437a 100644 --- a/src/kaitencloud/types/__init__.py +++ b/src/kaitencloud/types/__init__.py @@ -12,6 +12,20 @@ from ._core import ( AggregationMethod, + AiChargeOnError, + AiCredits, + AiInference, + AiInferencePage, + AiInferenceStatus, + AiRequestStatus, + AiRequestType, + AiReservations, + AiUnitType, + AiUsage, + AiUsageOutcome, + AiUsageOutcomeStatus, + AiUsagePhase, + AiUsageValue, AuditTrail, BasicTargeting, BasicVariant, @@ -23,6 +37,10 @@ ConnectorSettings, ConnectorSettingsSchema, ConnectorState, + ConversionRates, + ConversionRule, + ConversionRuleStatus, + ConversionRuleUpdated, Customer, CustomerCreationRejected, CustomerIntegration, @@ -38,10 +56,12 @@ EntitlementUsage, EntitlementValue, ErrorDetail, + EventHeaders, FeatureFlag, FeatureFlagEvaluated, FeatureFlagType, Instance, + InstanceAIModelUsageUnauthorized, InstanceEntitlementCapExceeded, InstanceEntitlementUsagePeriodRolledOver, InstanceEntitlementUsageReportAccepted, @@ -65,10 +85,16 @@ MetadataFieldImpactSample, MetadataFieldReordered, MetadataResourceType, + Model, + ModelModality, + ModelStatus, + ModelUpdated, NumberEntitlementValue, PlainToken, Problem, Release, + ReportEntitlementUsageBody, + ReportUsageValue, ResetAnchor, ResetPeriod, RolloutDateTargeting, @@ -92,11 +118,9 @@ UsageReportStatus, User, Variant, + VendorCosts, ) from ._params import ( - AiRequestStatus, - AiRequestType, - AiUnitType, AiUsageParam, BasicTargetingParam, BasicVariantParam, @@ -117,10 +141,21 @@ __all__ = [ "AggregationMethod", + "AiChargeOnError", + "AiCredits", + "AiInference", + "AiInferencePage", + "AiInferenceStatus", "AiRequestStatus", "AiRequestType", + "AiReservations", "AiUnitType", + "AiUsage", + "AiUsageOutcome", + "AiUsageOutcomeStatus", "AiUsageParam", + "AiUsagePhase", + "AiUsageValue", "AuditTrail", "BasicTargeting", "BasicTargetingParam", @@ -134,6 +169,10 @@ "ConnectorSettings", "ConnectorSettingsSchema", "ConnectorState", + "ConversionRates", + "ConversionRule", + "ConversionRuleStatus", + "ConversionRuleUpdated", "Customer", "CustomerCreationRejected", "CustomerIntegration", @@ -150,10 +189,12 @@ "EntitlementUsage", "EntitlementValue", "ErrorDetail", + "EventHeaders", "FeatureFlag", "FeatureFlagEvaluated", "FeatureFlagType", "Instance", + "InstanceAIModelUsageUnauthorized", "InstanceEntitlementCapExceeded", "InstanceEntitlementUsagePeriodRolledOver", "InstanceEntitlementUsageReportAccepted", @@ -178,12 +219,18 @@ "MetadataFieldImpactSample", "MetadataFieldReordered", "MetadataResourceType", + "Model", + "ModelModality", + "ModelStatus", + "ModelUpdated", "NumberEntitlementValue", "Organization", "PlainToken", "PlatformCredential", "Problem", "Release", + "ReportEntitlementUsageBody", + "ReportUsageValue", "ResetAnchor", "ResetPeriod", "RolloutDateTargeting", @@ -214,4 +261,5 @@ "User", "Variant", "VariantParam", + "VendorCosts", ] diff --git a/src/kaitencloud/types/_core.py b/src/kaitencloud/types/_core.py index 833b070..b03fa56 100644 --- a/src/kaitencloud/types/_core.py +++ b/src/kaitencloud/types/_core.py @@ -15,6 +15,20 @@ __all__ = [ "AggregationMethod", + "AiChargeOnError", + "AiCredits", + "AiInference", + "AiInferencePage", + "AiInferenceStatus", + "AiRequestStatus", + "AiRequestType", + "AiReservations", + "AiUnitType", + "AiUsage", + "AiUsageOutcome", + "AiUsageOutcomeStatus", + "AiUsagePhase", + "AiUsageValue", "AuditTrail", "BasicTargeting", "BasicVariant", @@ -26,6 +40,10 @@ "ConnectorSettings", "ConnectorSettingsSchema", "ConnectorState", + "ConversionRates", + "ConversionRule", + "ConversionRuleStatus", + "ConversionRuleUpdated", "Customer", "CustomerCreationRejected", "CustomerIntegration", @@ -41,10 +59,12 @@ "EntitlementUsage", "EntitlementValue", "ErrorDetail", + "EventHeaders", "FeatureFlag", "FeatureFlagEvaluated", "FeatureFlagType", "Instance", + "InstanceAIModelUsageUnauthorized", "InstanceEntitlementCapExceeded", "InstanceEntitlementUsagePeriodRolledOver", "InstanceEntitlementUsageReportAccepted", @@ -68,10 +88,16 @@ "MetadataFieldImpactSample", "MetadataFieldReordered", "MetadataResourceType", + "Model", + "ModelModality", + "ModelStatus", + "ModelUpdated", "NumberEntitlementValue", "PlainToken", "Problem", "Release", + "ReportEntitlementUsageBody", + "ReportUsageValue", "ResetAnchor", "ResetPeriod", "RolloutDateTargeting", @@ -95,12 +121,29 @@ "UsageReportStatus", "User", "Variant", + "VendorCosts", ] AggregationMethod = Literal["COUNT", "SUM", "AVERAGE", "MIN", "MAX", "LATEST"] """How each reported usage value folds into an entitlement's stored total.""" +AiChargeOnError = Literal["NEVER", "ALWAYS", "PROVIDER_ERROR_ONLY"] + +AiInferenceStatus = Literal["RESERVED", "COMMITTED", "RELEASED", "EXPIRED"] + +AiRequestStatus = Literal["SUCCESS", "PROVIDER_ERROR", "CUSTOMER_ERROR", "NOT_SENT"] + +AiRequestType = Literal["COMPLETION", "EMBEDDING", "IMAGE_GENERATION", "AUDIO", "VIDEO"] + +AiUnitType = Literal["image", "second", "frame"] + +AiUsageOutcomeStatus = Literal["RESERVED", "COMMITTED", "RELEASED"] + +AiUsagePhase = Literal["reserve", "commit"] + +ConversionRuleStatus = Literal["ACTIVE", "ARCHIVED"] + EntitlementType = Literal["BOOLEAN", "NUMBER", "CONFIG", "NUMBER_AI_CREDIT"] """The value type of an entitlement in the catalog.""" @@ -124,6 +167,10 @@ MetadataResourceType = Literal["DEPLOYMENT_ZONE", "INSTANCE"] """The resource a metadata field applies to.""" +ModelModality = Literal["TEXT", "IMAGE", "AUDIO", "VIDEO", "EMBEDDING", "MULTIMODAL"] + +ModelStatus = Literal["ACTIVE", "DEPRECATED", "ARCHIVED"] + ResetAnchor = Literal["CALENDAR", "LICENSE_START"] """What a periodic usage window is aligned on.""" @@ -139,6 +186,7 @@ "read:instances", "read:licenses", "read:metadata_fields", + "read:models", "read:notifications", "read:organizations", "read:releases", @@ -152,6 +200,7 @@ "write:instances", "write:licenses", "write:metadata_fields", + "write:models", "write:notifications", "write:organizations", "write:releases", @@ -172,6 +221,114 @@ """Whether a usage report was persisted.""" +class AiCredits(KaitenModel): + charged: str | None = None + + computed: str | None = None + + reserved: str | None = None + + +class AiInference(KaitenModel): + authorized: bool | None = None + + committed_at: datetime | None = Field(default=None, alias="committedAt") + + conversion_rule_id: str | None = Field(default=None, alias="conversionRuleId") + + credits: AiCredits + + denial_reason: str | None = Field(default=None, alias="denialReason") + + expires_at: datetime | None = Field(default=None, alias="expiresAt") + + model: str + + model_slug: str | None = Field(default=None, alias="modelSlug") + + reported_at: datetime = Field(alias="reportedAt") + + request_status: str | None = Field(default=None, alias="requestStatus") + + request_type: AiRequestType | str = Field(alias="requestType") + + status: AiInferenceStatus | str + + transaction_id: str = Field(alias="transactionId") + + usage: AiUsage + + value_after: float | None = Field(default=None, alias="valueAfter") + + value_before: float | None = Field(default=None, alias="valueBefore") + + window_start: datetime | None = Field(default=None, alias="windowStart") + + +class AiInferencePage(KaitenModel): + has_more: bool = Field(alias="hasMore") + + items: list[AiInference] | None + + next_cursor: str | None = Field(default=None, alias="nextCursor") + + +class AiReservations(KaitenModel): + items: list[AiInference] | None + + +class AiUsage(KaitenModel): + cache_read_input_tokens: int | None = Field(default=None, alias="cacheReadInputTokens") + + cache_write_input_tokens: int | None = Field(default=None, alias="cacheWriteInputTokens") + + duration_ms: int | None = Field(default=None, alias="durationMs") + + input_tokens: int | None = Field(default=None, alias="inputTokens") + + output_tokens: int | None = Field(default=None, alias="outputTokens") + + unit_type: AiUnitType | str | None = Field(default=None, alias="unitType") + + units: str | None = None + + +class AiUsageOutcome(KaitenModel): + authorized: bool | None = None + + credits: AiCredits + + expires_at: datetime | None = Field(default=None, alias="expiresAt") + + phase: AiUsagePhase | str + + remaining: str | None + + status: AiUsageOutcomeStatus | str + + transaction_id: str = Field(alias="transactionId") + + warnings: list[str] + + +class AiUsageValue(KaitenModel): + model: str | None = None + + phase: AiUsagePhase | str + + request_status: AiRequestStatus | str | None = Field(default=None, alias="requestStatus") + + request_type: AiRequestType | str | None = Field(default=None, alias="requestType") + + transaction_id: str | None = Field(default=None, alias="transactionId") + + ttl_seconds: int | None = Field(default=None, alias="ttlSeconds") + + type: Literal["ai_usage"] + + usage: AiUsage | None = None + + class AuditTrail(KaitenModel): """One entry of an instance's audit trail.""" @@ -333,18 +490,68 @@ class ConnectorState(KaitenModel): """When this organization activated the connector. Absent when it has not.""" available: bool - """Whether this deployment has the connector registered""" + """Whether this installation has the connector registered""" connector_name: str = Field(alias="connectorName") """Stable connector identifier""" entitled: bool - """Whether this organization's licence includes the connector. True for an ungated connector.""" + """Whether this organization's license includes the connector. True for an ungated connector.""" version: str | None = None """Registered connector version. Absent when the connector is not available.""" +class ConversionRates(KaitenModel): + per_cache_write_token: str | None = Field(default=None, alias="perCacheWriteToken") + + per_cached_input_token: str | None = Field(default=None, alias="perCachedInputToken") + + per_input_token: str | None = Field(default=None, alias="perInputToken") + + per_output_token: str | None = Field(default=None, alias="perOutputToken") + + per_request: str | None = Field(default=None, alias="perRequest") + + per_second: str | None = Field(default=None, alias="perSecond") + + per_unit: str | None = Field(default=None, alias="perUnit") + + +class ConversionRule(KaitenModel): + charge_on_error: AiChargeOnError | str = Field(alias="chargeOnError") + + created_at: datetime = Field(alias="createdAt") + + entitlement_slug: str = Field(alias="entitlementSlug") + + granted_by_license: bool = Field(alias="grantedByLicense") + + id: str + + license_slug: str = Field(alias="licenseSlug") + + maximum_credits_per_request: str | None = Field(alias="maximumCreditsPerRequest") + + minimum_credits_per_request: str = Field(alias="minimumCreditsPerRequest") + + model_slug: str | None = Field(alias="modelSlug") + + rates: ConversionRates + + request_type: AiRequestType | str | None = Field(alias="requestType") + + status: ConversionRuleStatus | str + + updated_at: datetime = Field(alias="updatedAt") + + +class ConversionRuleUpdated(KaitenModel): + after: ConversionRule + + before: ConversionRule + + class Customer(KaitenModel): """A customer of your organization: the end client instances are deployed for.""" @@ -647,6 +854,9 @@ class EntitlementGroupUsage(KaitenModel): class EntitlementUsage(KaitenModel): """An instance's consumption of one entitlement, next to the grant it is measured against.""" + ai: AiUsageOutcome | None = None + """Outcome of this AI report; absent on ordinary reads""" + current_period_end: datetime | None = Field(default=None, alias="currentPeriodEnd") """End of the current usage window (exclusive). Null for a lifetime entitlement (no configured reset period). @@ -674,6 +884,9 @@ class EntitlementUsage(KaitenModel): when the license grants no value; -1 means unlimited for a NUMBER entitlement. """ + reserved: str | None = None + """Open, unexpired reserved credits; present only on AI credit pools""" + value: EntitlementValue """Resolved entitlement value, discriminated by the 'type' field""" @@ -691,6 +904,22 @@ class ErrorDetail(KaitenModel): """The value at the given location""" +class EventHeaders(KaitenModel): + baggage: str | None = None + """W3C baggage of that request, when it carried any""" + + instance_id: str | None = None + """The instance the event concerns, set on the events of one instance. The usage events' + payloads do not name it. + """ + + traceparent: str | None = None + """W3C trace context of the request that produced the event""" + + tracestate: str | None = None + """W3C trace state of that request, when it carried one""" + + class FeatureFlag(KaitenModel): """A feature flag: its variants, targeting rules and default variant.""" @@ -812,6 +1041,16 @@ class Instance(KaitenModel): """User who last updated this instance""" +class InstanceAIModelUsageUnauthorized(KaitenModel): + entitlement_slug: str + + model: str + + reason: str + + request_type: str + + class InstanceEntitlementCapExceeded(KaitenModel): """Usage crossed an entitlement's included value, within its allowance.""" @@ -1257,6 +1496,43 @@ class MetadataFieldReordered(KaitenModel): """Metadata field ids in their new display order""" +class Model(KaitenModel): + created_at: datetime = Field(alias="createdAt") + + description: str | None = None + + external_ids: list[str] | None = Field(default=None, alias="externalIds") + """Exact provider identifiers, unique per organization; cannot shadow another model's slug""" + + id: str + + metadata: dict[str, Any] | None = None + + modality: ModelModality | str + + name: str + + slug: str + """Immutable URL-friendly identifier, unique per organization""" + + status: ModelStatus | str + """ACTIVE authorizes usage; DEPRECATED keeps existing rules; ARCHIVED authorizes none. + Reversible. + """ + + updated_at: datetime = Field(alias="updatedAt") + + vendor: str + + vendor_costs: VendorCosts | None = Field(default=None, alias="vendorCosts") + + +class ModelUpdated(KaitenModel): + after: Model + + before: Model + + class NumberEntitlementValue(KaitenModel): """A numeric entitlement value: a quota, or an amount consumed.""" @@ -1357,6 +1633,21 @@ class Release(KaitenModel): """Version of the release""" +class ReportEntitlementUsageBody(KaitenModel): + behavior: UsageBehavior | str | None = None + """Report behavior: append folds the value into the stored total through the aggregation + method; set overwrites it + """ + + metadata: dict[str, Any] | None = None + """Optional metadata for the usage report""" + + value: ReportUsageValue + """Reported entitlement value, discriminated by the 'type' field. Usage reporting accepts + the number variant only. + """ + + class RolloutDateTargeting(KaitenModel): """A targeting rule that shifts traffic between variants over a date range.""" @@ -1471,6 +1762,9 @@ class TargetingContextNode(KaitenModel): description: str | None = None """What this value means, shown by the editor""" + entries: list[TargetingContextNode] | None = None + """Shape of each known key, for a map whose entries differ. Named after the key""" + fields: list[TargetingContextNode] | None = None """Named children, for an object""" @@ -1586,6 +1880,22 @@ class Variant(KaitenModel): value: Any +class VendorCosts(KaitenModel): + currency: Literal["USD"] + + per_cache_write_token: str | None = Field(default=None, alias="perCacheWriteToken") + + per_cached_input_token: str | None = Field(default=None, alias="perCachedInputToken") + + per_input_token: str | None = Field(default=None, alias="perInputToken") + + per_output_token: str | None = Field(default=None, alias="perOutputToken") + + per_request: str | None = Field(default=None, alias="perRequest") + + per_unit: str | None = Field(default=None, alias="perUnit") + + DefaultVariant = Annotated[ BasicVariant | RolloutDateVariant | RolloutPercentageVariant, Field(discriminator="type") ] @@ -1597,6 +1907,8 @@ class Variant(KaitenModel): ] """A typed entitlement value, discriminated by ``type``: a number, a boolean or a config object.""" +ReportUsageValue = Annotated[NumberEntitlementValue | AiUsageValue, Field(discriminator="type")] + Targeting = Annotated[ BasicTargeting | RolloutDateTargeting | RolloutPercentageTargeting, Field(discriminator="type") ] @@ -1607,6 +1919,13 @@ class Variant(KaitenModel): for _model in ( + AiCredits, + AiInference, + AiInferencePage, + AiReservations, + AiUsage, + AiUsageOutcome, + AiUsageValue, AuditTrail, BasicTargeting, BasicVariant, @@ -1618,6 +1937,9 @@ class Variant(KaitenModel): ConnectorSettings, ConnectorSettingsSchema, ConnectorState, + ConversionRates, + ConversionRule, + ConversionRuleUpdated, Customer, CustomerCreationRejected, CustomerIntegration, @@ -1630,9 +1952,11 @@ class Variant(KaitenModel): EntitlementGroupUsage, EntitlementUsage, ErrorDetail, + EventHeaders, FeatureFlag, FeatureFlagEvaluated, Instance, + InstanceAIModelUsageUnauthorized, InstanceEntitlementCapExceeded, InstanceEntitlementUsagePeriodRolledOver, InstanceEntitlementUsageReportAccepted, @@ -1651,10 +1975,13 @@ class Variant(KaitenModel): MetadataFieldImpact, MetadataFieldImpactSample, MetadataFieldReordered, + Model, + ModelUpdated, NumberEntitlementValue, PlainToken, Problem, Release, + ReportEntitlementUsageBody, RolloutDateTargeting, RolloutDateVariant, RolloutPercentageTargeting, @@ -1670,6 +1997,7 @@ class Variant(KaitenModel): Token, User, Variant, + VendorCosts, ): _model.model_rebuild() del _model diff --git a/src/kaitencloud/types/_params.py b/src/kaitencloud/types/_params.py index c4e6162..464ec4f 100644 --- a/src/kaitencloud/types/_params.py +++ b/src/kaitencloud/types/_params.py @@ -20,10 +20,9 @@ from typing_extensions import Required, TypedDict +from ._core import AiUnitType + __all__ = [ - "AiRequestStatus", - "AiRequestType", - "AiUnitType", "AiUsageParam", "BasicTargetingParam", "BasicVariantParam", @@ -131,16 +130,6 @@ class RolloutDateTargetingParam(TypedDict): ) -AiRequestType: TypeAlias = Literal["COMPLETION", "EMBEDDING", "IMAGE_GENERATION", "AUDIO", "VIDEO"] -"""What an AI call produced, which picks the conversion rule that prices it.""" - -AiRequestStatus: TypeAlias = Literal["SUCCESS", "PROVIDER_ERROR", "CUSTOMER_ERROR", "NOT_SENT"] -"""How an AI call ended. ``NOT_SENT``: the provider was never reached, and nothing is charged.""" - -AiUnitType: TypeAlias = Literal["image", "second", "frame"] -"""What ``units`` counts in an :class:`AiUsageParam`.""" - - class AiUsageParam(TypedDict, total=False): """What an AI call consumed -- or, on a reservation, what it is expected to. diff --git a/src/kaitencloud/types/_webhooks.py b/src/kaitencloud/types/_webhooks.py index 0cc73bc..ac68bb4 100644 --- a/src/kaitencloud/types/_webhooks.py +++ b/src/kaitencloud/types/_webhooks.py @@ -15,6 +15,8 @@ from .._models import KaitenModel from ._core import ( Component, + ConversionRule, + ConversionRuleUpdated, Customer, CustomerCreationRejected, Deployment, @@ -25,6 +27,7 @@ FeatureFlag, FeatureFlagEvaluated, Instance, + InstanceAIModelUsageUnauthorized, InstanceEntitlementCapExceeded, InstanceEntitlementUsagePeriodRolledOver, InstanceEntitlementUsageReportAccepted, @@ -37,15 +40,19 @@ LicenseFamilyView, MetadataField, MetadataFieldReordered, + Model, + ModelUpdated, Release, SystemTokenIssuance, ) __all__ = [ "WEBHOOK_EVENTS", + "ArchiveConversionRuleEvent", "ComponentCreatedEvent", "ComponentDeletedEvent", "ComponentUpdatedEvent", + "CreateConversionRuleEvent", "CustomerCreatedEvent", "CustomerCreationRejectedEvent", "CustomerDeletedEvent", @@ -63,10 +70,12 @@ "FeatureFlagDeletedEvent", "FeatureFlagEvaluatedEvent", "FeatureFlagUpdatedEvent", + "InstanceAIModelUsageUnauthorizedEvent", "InstanceCreatedEvent", "InstanceDeletedEvent", "InstanceDeployedEvent", "InstanceEntitlementCapExceededEvent", + "InstanceEntitlementMaximumExceededEvent", "InstanceEntitlementUsagePeriodRolledOverEvent", "InstanceEntitlementUsageReachedEvent", "InstanceEntitlementUsageReportAcceptedEvent", @@ -94,10 +103,14 @@ "MetadataFieldReorderedEvent", "MetadataFieldUnarchivedEvent", "MetadataFieldUpdatedEvent", + "ModelCreatedEvent", + "ModelDeletedEvent", + "ModelUpdatedEvent", "ReleaseCreatedEvent", "ReleaseDeletedEvent", "ReleaseDeployedEvent", "SystemOrganizationTokenIssuedEvent", + "UpdateConversionRuleEvent", "WebhookEvent", ] @@ -416,8 +429,9 @@ class SystemOrganizationTokenIssuedEvent(KaitenModel): """Triggered when a platform credential mints an organization-scoped credential for the `system:kaiten` platform identity inside this organization. Carries the platform credential that performed the mint and the scopes granted, never the credential value. - Expect this at the rate an integration refreshes: roughly 120 per day per process at a - 15-minute lifetime. Delivered as ``com.kaiten.identity.v1.system_token_issued``. + Expect this at the rate an integration refreshes: 96 a day per process at a 15-minute + lifetime, more if it refreshes before expiry. Delivered as + ``com.kaiten.identity.v1.system_token_issued``. """ name: Literal["SYSTEM_ORGANIZATION_TOKEN_ISSUED"] @@ -430,6 +444,21 @@ class SystemOrganizationTokenIssuedEvent(KaitenModel): """What the event is about.""" +class InstanceAIModelUsageUnauthorizedEvent(KaitenModel): + """AI usage was recorded without an authorized model conversion rule. Delivered as + ``com.kaiten.instance.ai.v1.model_usage_unauthorized``. + """ + + name: Literal["INSTANCE_AI_MODEL_USAGE_UNAUTHORIZED"] + """The event name.""" + + type: Literal["com.kaiten.instance.ai.v1.model_usage_unauthorized"] + """The event type: what tells one event from another.""" + + data: InstanceAIModelUsageUnauthorized + """What the event is about.""" + + class InstanceEntitlementCapExceededEvent(KaitenModel): """Triggered when a SOFT-enforcement entitlement's usage crosses from below the cap to above it. Fires once per crossing. Delivered as @@ -446,6 +475,21 @@ class InstanceEntitlementCapExceededEvent(KaitenModel): """What the event is about.""" +class InstanceEntitlementMaximumExceededEvent(KaitenModel): + """A committed AI usage charge crossed a soft pool's maximum. Delivered as + ``com.kaiten.instance.entitlement.v1.maximum_exceeded``. + """ + + name: Literal["INSTANCE_ENTITLEMENT_MAXIMUM_EXCEEDED"] + """The event name.""" + + type: Literal["com.kaiten.instance.entitlement.v1.maximum_exceeded"] + """The event type: what tells one event from another.""" + + data: InstanceEntitlementCapExceeded + """What the event is about.""" + + class InstanceEntitlementUsagePeriodRolledOverEvent(KaitenModel): """Triggered once per usage window closed by a report: the entitlement's own stored bucket, and one synthetic event per window skipped entirely since the last report. Delivered as @@ -639,6 +683,45 @@ class InstanceUpdatedEvent(KaitenModel): """What the event is about.""" +class ArchiveConversionRuleEvent(KaitenModel): + """Conversion rule change. Delivered as ``com.kaiten.license.conversion_rule.v1.archived``.""" + + name: Literal["LICENSE_CONVERSION_RULE_ARCHIVED"] + """The event name.""" + + type: Literal["com.kaiten.license.conversion_rule.v1.archived"] + """The event type: what tells one event from another.""" + + data: ConversionRule + """What the event is about.""" + + +class CreateConversionRuleEvent(KaitenModel): + """Conversion rule change. Delivered as ``com.kaiten.license.conversion_rule.v1.created``.""" + + name: Literal["LICENSE_CONVERSION_RULE_CREATED"] + """The event name.""" + + type: Literal["com.kaiten.license.conversion_rule.v1.created"] + """The event type: what tells one event from another.""" + + data: ConversionRule + """What the event is about.""" + + +class UpdateConversionRuleEvent(KaitenModel): + """Conversion rule change. Delivered as ``com.kaiten.license.conversion_rule.v1.updated``.""" + + name: Literal["LICENSE_CONVERSION_RULE_UPDATED"] + """The event name.""" + + type: Literal["com.kaiten.license.conversion_rule.v1.updated"] + """The event type: what tells one event from another.""" + + data: ConversionRuleUpdated + """What the event is about.""" + + class LicenseEntitlementAssignedEvent(KaitenModel): """Triggered when an entitlement is assigned to a license. Delivered as ``com.kaiten.license.entitlement.v1.assigned``. @@ -897,6 +980,45 @@ class MetadataFieldUpdatedEvent(KaitenModel): """What the event is about.""" +class ModelCreatedEvent(KaitenModel): + """Triggered when a catalogue model is created. Delivered as ``com.kaiten.model.v1.created``.""" + + name: Literal["MODEL_CREATED"] + """The event name.""" + + type: Literal["com.kaiten.model.v1.created"] + """The event type: what tells one event from another.""" + + data: Model + """What the event is about.""" + + +class ModelDeletedEvent(KaitenModel): + """Triggered when a catalogue model is deleted. Delivered as ``com.kaiten.model.v1.deleted``.""" + + name: Literal["MODEL_DELETED"] + """The event name.""" + + type: Literal["com.kaiten.model.v1.deleted"] + """The event type: what tells one event from another.""" + + data: Model + """What the event is about.""" + + +class ModelUpdatedEvent(KaitenModel): + """Triggered when a catalogue model is updated. Delivered as ``com.kaiten.model.v1.updated``.""" + + name: Literal["MODEL_UPDATED"] + """The event name.""" + + type: Literal["com.kaiten.model.v1.updated"] + """The event type: what tells one event from another.""" + + data: ModelUpdated + """What the event is about.""" + + class ReleaseCreatedEvent(KaitenModel): """Triggered when a new release is created. Delivered as ``com.kaiten.release.v1.created``.""" @@ -946,7 +1068,9 @@ class ReleaseDeletedEvent(KaitenModel): | FeatureFlagEvaluatedEvent | FeatureFlagUpdatedEvent | SystemOrganizationTokenIssuedEvent + | InstanceAIModelUsageUnauthorizedEvent | InstanceEntitlementCapExceededEvent + | InstanceEntitlementMaximumExceededEvent | InstanceEntitlementUsagePeriodRolledOverEvent | InstanceEntitlementUsageReachedEvent | InstanceEntitlementUsageReportAcceptedEvent @@ -960,6 +1084,9 @@ class ReleaseDeletedEvent(KaitenModel): | InstanceMigratedEvent | InstanceStatusChangedEvent | InstanceUpdatedEvent + | ArchiveConversionRuleEvent + | CreateConversionRuleEvent + | UpdateConversionRuleEvent | LicenseEntitlementAssignedEvent | LicenseEntitlementUnassignedEvent | LicenseEntitlementUpdatedEvent @@ -977,6 +1104,9 @@ class ReleaseDeletedEvent(KaitenModel): | MetadataFieldReorderedEvent | MetadataFieldUnarchivedEvent | MetadataFieldUpdatedEvent + | ModelCreatedEvent + | ModelDeletedEvent + | ModelUpdatedEvent | ReleaseCreatedEvent | ReleaseDeletedEvent, Field(discriminator="type"), @@ -1006,7 +1136,9 @@ class ReleaseDeletedEvent(KaitenModel): "com.kaiten.feature_flag.v1.evaluated": FeatureFlagEvaluatedEvent, "com.kaiten.feature_flag.v1.updated": FeatureFlagUpdatedEvent, "com.kaiten.identity.v1.system_token_issued": SystemOrganizationTokenIssuedEvent, + "com.kaiten.instance.ai.v1.model_usage_unauthorized": InstanceAIModelUsageUnauthorizedEvent, "com.kaiten.instance.entitlement.v1.cap_exceeded": InstanceEntitlementCapExceededEvent, + "com.kaiten.instance.entitlement.v1.maximum_exceeded": InstanceEntitlementMaximumExceededEvent, "com.kaiten.instance.entitlement.v1.usage_period_rolled_over": InstanceEntitlementUsagePeriodRolledOverEvent, "com.kaiten.instance.entitlement.v1.usage_reached": InstanceEntitlementUsageReachedEvent, "com.kaiten.instance.entitlement.v1.usage_report_accepted": InstanceEntitlementUsageReportAcceptedEvent, @@ -1020,6 +1152,9 @@ class ReleaseDeletedEvent(KaitenModel): "com.kaiten.instance.v1.migrated": InstanceMigratedEvent, "com.kaiten.instance.v1.status_changed": InstanceStatusChangedEvent, "com.kaiten.instance.v1.updated": InstanceUpdatedEvent, + "com.kaiten.license.conversion_rule.v1.archived": ArchiveConversionRuleEvent, + "com.kaiten.license.conversion_rule.v1.created": CreateConversionRuleEvent, + "com.kaiten.license.conversion_rule.v1.updated": UpdateConversionRuleEvent, "com.kaiten.license.entitlement.v1.assigned": LicenseEntitlementAssignedEvent, "com.kaiten.license.entitlement.v1.unassigned": LicenseEntitlementUnassignedEvent, "com.kaiten.license.entitlement.v1.updated": LicenseEntitlementUpdatedEvent, @@ -1037,6 +1172,9 @@ class ReleaseDeletedEvent(KaitenModel): "com.kaiten.metadata_field.v1.reordered": MetadataFieldReorderedEvent, "com.kaiten.metadata_field.v1.unarchived": MetadataFieldUnarchivedEvent, "com.kaiten.metadata_field.v1.updated": MetadataFieldUpdatedEvent, + "com.kaiten.model.v1.created": ModelCreatedEvent, + "com.kaiten.model.v1.deleted": ModelDeletedEvent, + "com.kaiten.model.v1.updated": ModelUpdatedEvent, "com.kaiten.release.v1.created": ReleaseCreatedEvent, "com.kaiten.release.v1.deleted": ReleaseDeletedEvent, } diff --git a/tests/test_contract.py b/tests/test_contract.py index 242e787..3cce81f 100644 --- a/tests/test_contract.py +++ b/tests/test_contract.py @@ -35,6 +35,7 @@ from helpers import ORG_ID, USER_ID, Recorder, body from kaitencloud import targeting +from kaitencloud.types import ConversionRates ID = "5d1e8f3a-2b4c-4d6e-8f0a-1b2c3d4e5f60" SAMPLE_ID = "0f9e8d7c-6b5a-4938-8271-605f4e3d2c1b" @@ -657,6 +658,86 @@ class Case: ] +CASES.extend( + [ + Case( + "listModels", + lambda c: c.models.list(status="ACTIVE", vendor="synthetic", modality="TEXT"), + ), + Case("getModel", lambda c: c.models.get("synthetic-model")), + Case( + "createModel", + lambda c: c.models.create( + name="Synthetic", slug="synthetic-model", vendor="synthetic", modality="TEXT" + ), + ), + Case( + "updateModel", + lambda c: c.models.update( + "synthetic-model", name="Synthetic", vendor="synthetic", modality="TEXT" + ), + ), + Case("deleteModel", lambda c: c.models.delete("synthetic-model")), + Case( + "listConversionRules", + lambda c: c.licenses.list_conversion_rules( + "pro", entitlement_slug="credits", model_slug="synthetic-model" + ), + ), + Case("getConversionRule", lambda c: c.licenses.get_conversion_rule("pro", ID)), + Case( + "createConversionRule", + lambda c: c.licenses.create_conversion_rule( + "pro", entitlement_slug="credits", rates=ConversionRates(perRequest="1") + ), + ), + Case( + "updateConversionRule", + lambda c: c.licenses.update_conversion_rule( + "pro", ID, rates=ConversionRates(perRequest="2") + ), + ), + Case("archiveConversionRule", lambda c: c.licenses.archive_conversion_rule("pro", ID)), + Case( + "listAiInferences", + lambda c: c.instances.list_ai_inferences( + "acme", + "credits", + from_=NOW, + to=LATER, + status="COMMITTED", + transaction_id="test:1", + limit=5, + ), + ), + Case("listAiReservations", lambda c: c.instances.list_ai_reservations("acme", "credits")), + Case( + "reportEntitlementUsageMetric", + lambda c: c.instances.reserve_ai_usage( + "acme", + "credits", + model="synthetic-model", + transaction_id="test:1", + usage={"input_tokens": 10}, + ), + name="ai-reserve", + ), + Case( + "reportEntitlementUsageMetric", + lambda c: c.instances.commit_ai_usage( + "acme", + "credits", + transaction_id="test:1", + model="synthetic-model", + request_type="COMPLETION", + usage={"output_tokens": 2}, + ), + name="ai-commit", + ), + ] +) + + def _invocations() -> list[Any]: params = [] for case in CASES: diff --git a/tests/test_models.py b/tests/test_models.py index 245e7f8..fef0e1a 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -134,3 +134,39 @@ def test_a_flag_parses_its_default_variant_and_rules_by_type() -> None: def test_types_exports_the_parameter_shapes_too() -> None: assert {"IntegrationParam", "VariantParam", "TargetingParam"} <= set(types.__all__) + + +def test_report_usage_union_is_generated_without_widening_entitlement_values() -> None: + from pydantic import TypeAdapter, ValidationError + + from kaitencloud._ai_models import AiEntitlementUsage + from kaitencloud.types import AiUsageValue, EntitlementValue, ReportUsageValue + + payload = { + "type": "ai_usage", + "phase": "reserve", + "transactionId": "test:1", + "model": "synthetic", + "requestType": "COMPLETION", + } + assert isinstance(TypeAdapter(ReportUsageValue).validate_python(payload), AiUsageValue) + assert TypeAdapter(ReportUsageValue).validate_python({"type": "number", "value": 2}).value == 2 + with pytest.raises(ValidationError): + TypeAdapter(EntitlementValue).validate_python(payload) + assert AiEntitlementUsage is EntitlementUsage + + +def test_nullable_rule_request_type_reuses_the_generated_enum() -> None: + from generate_models import ModelModule + + module = ModelModule(source="synthetic", schemas={}, names={}) + values = ["COMPLETION", "EMBEDDING", "IMAGE_GENERATION", "AUDIO", "VIDEO"] + module.annotation({"type": "string", "enum": values}, owner="AiInference", prop="requestType") + assert ( + module.annotation( + {"type": ["string", "null"], "enum": [*values, None]}, + owner="ConversionRule", + prop="requestType", + ) + == "AiRequestType | str | None" + ) From e7197d608fc00f54a91a0029c06a714e12e9ca13 Mon Sep 17 00:00:00 2001 From: tomflenner Date: Mon, 19 Oct 2026 13:55:44 +0200 Subject: [PATCH 4/8] docs(api): sync final AI schema examples Signed-off-by: tomflenner --- openapi/openapi.yaml | 32 ++++++++++++++++++++++++++++++++ openapi/source.yaml | 4 ++-- 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml index 42a5d70..abf30bc 100644 --- a/openapi/openapi.yaml +++ b/openapi/openapi.yaml @@ -537,36 +537,50 @@ components: additionalProperties: false properties: perCacheWriteToken: + examples: + - "0.001" pattern: ^[0-9]{1,8}(\.[0-9]{1,10})?$ type: - string - "null" perCachedInputToken: + examples: + - "0.001" pattern: ^[0-9]{1,8}(\.[0-9]{1,10})?$ type: - string - "null" perInputToken: + examples: + - "0.001" pattern: ^[0-9]{1,8}(\.[0-9]{1,10})?$ type: - string - "null" perOutputToken: + examples: + - "0.001" pattern: ^[0-9]{1,8}(\.[0-9]{1,10})?$ type: - string - "null" perRequest: + examples: + - "0.001" pattern: ^[0-9]{1,8}(\.[0-9]{1,10})?$ type: - string - "null" perSecond: + examples: + - "0.001" pattern: ^[0-9]{1,8}(\.[0-9]{1,10})?$ type: - string - "null" perUnit: + examples: + - "0.001" pattern: ^[0-9]{1,8}(\.[0-9]{1,10})?$ type: - string @@ -594,11 +608,15 @@ components: licenseSlug: type: string maximumCreditsPerRequest: + examples: + - "0.001" pattern: ^[0-9]{1,12}(\.[0-9]{1,6})?$ type: - string - "null" minimumCreditsPerRequest: + examples: + - "0.001" pattern: ^[0-9]{1,12}(\.[0-9]{1,6})?$ type: string modelSlug: @@ -2550,6 +2568,8 @@ components: type: string slug: description: Immutable URL-friendly identifier, unique per organization + examples: + - sample-model maxLength: 100 minLength: 2 pattern: ^[a-z0-9][a-z0-9-]*[a-z0-9]$ @@ -3741,21 +3761,33 @@ components: - USD type: string perCacheWriteToken: + examples: + - "0.001" pattern: ^[0-9]{1,6}(\.[0-9]{1,12})?$ type: string perCachedInputToken: + examples: + - "0.001" pattern: ^[0-9]{1,6}(\.[0-9]{1,12})?$ type: string perInputToken: + examples: + - "0.001" pattern: ^[0-9]{1,6}(\.[0-9]{1,12})?$ type: string perOutputToken: + examples: + - "0.001" pattern: ^[0-9]{1,6}(\.[0-9]{1,12})?$ type: string perRequest: + examples: + - "0.001" pattern: ^[0-9]{1,6}(\.[0-9]{1,12})?$ type: string perUnit: + examples: + - "0.001" pattern: ^[0-9]{1,6}(\.[0-9]{1,12})?$ type: string required: diff --git a/openapi/source.yaml b/openapi/source.yaml index b8a1bf8..cf3ac0f 100644 --- a/openapi/source.yaml +++ b/openapi/source.yaml @@ -1,8 +1,8 @@ # Where the two snapshots in this directory were copied from. Rewritten by # `task sync:openapi`; never edit the snapshots themselves by hand. repository: kaitencloud/kaiten -ref: HEAD -commit: bcdbf36829ceb93b0c2d9a4c1ac3d8d141ce5144 +ref: feat/ai-credits +commit: de1b0bc98f02169cb288227d485c5688bde87dbd synced_at: "2026-10-19" files: openapi.yaml: app/openapi.yaml From b0d55dcdbadea9114e3d38a94a8f469c1a2d63ea Mon Sep 17 00:00:00 2001 From: tomflenner Date: Mon, 19 Oct 2026 14:47:45 +0200 Subject: [PATCH 5/8] fix(api): align regenerated schemas with Core CI naming Signed-off-by: tomflenner --- openapi/openapi.yaml | 12 ++++++------ openapi/source.yaml | 2 +- src/kaitencloud/types/_core.py | 2 +- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml index abf30bc..d497046 100644 --- a/openapi/openapi.yaml +++ b/openapi/openapi.yaml @@ -670,7 +670,7 @@ components: - before - after type: object - CreateConversionRuleBody: + CreateConversionRule: additionalProperties: false properties: chargeOnError: @@ -1726,12 +1726,12 @@ components: type: string reason: type: string - request_type: + requestType: type: string required: - entitlement_slug - model - - request_type + - requestType - reason type: object InstanceBody: @@ -3687,7 +3687,7 @@ components: - createdAt - createdBy type: object - UpdateConversionRuleBody: + UpdateConversionRule: additionalProperties: false properties: chargeOnError: @@ -8983,7 +8983,7 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/CreateConversionRuleBody" + $ref: "#/components/schemas/CreateConversionRule" required: true responses: "201": @@ -9190,7 +9190,7 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/UpdateConversionRuleBody" + $ref: "#/components/schemas/UpdateConversionRule" required: true responses: "200": diff --git a/openapi/source.yaml b/openapi/source.yaml index cf3ac0f..2f6403e 100644 --- a/openapi/source.yaml +++ b/openapi/source.yaml @@ -2,7 +2,7 @@ # `task sync:openapi`; never edit the snapshots themselves by hand. repository: kaitencloud/kaiten ref: feat/ai-credits -commit: de1b0bc98f02169cb288227d485c5688bde87dbd +commit: 51224227aaad2c3d2a990440ff48c66db6fbd8e6 synced_at: "2026-10-19" files: openapi.yaml: app/openapi.yaml diff --git a/src/kaitencloud/types/_core.py b/src/kaitencloud/types/_core.py index b03fa56..9d18698 100644 --- a/src/kaitencloud/types/_core.py +++ b/src/kaitencloud/types/_core.py @@ -1048,7 +1048,7 @@ class InstanceAIModelUsageUnauthorized(KaitenModel): reason: str - request_type: str + request_type: str = Field(alias="requestType") class InstanceEntitlementCapExceeded(KaitenModel): From 3b5d9de2f6e635cfbb68de0f2f723376a8d72fab Mon Sep 17 00:00:00 2001 From: tomflenner Date: Mon, 19 Oct 2026 16:44:06 +0200 Subject: [PATCH 6/8] refactor(ai): select credit entitlements with entitlement keyword Signed-off-by: tomflenner --- CHANGELOG.md | 6 ++ README.md | 2 +- internal-docs/AI_WRAPPERS.md | 4 +- src/kaitencloud/_async/ai.py | 24 ++--- src/kaitencloud/_provider_clients.py | 29 ++++-- src/kaitencloud/_sync/ai.py | 24 ++--- src/kaitencloud/ai.py | 30 +++---- src/kaitencloud/anthropic.py | 6 +- src/kaitencloud/openai.py | 6 +- tests/test_ai.py | 88 ++++++++++++++---- tests/test_provider_compatibility.py | 12 +-- tests/test_providers.py | 128 +++++++++++++++++++++------ 12 files changed, 259 insertions(+), 100 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 348bffd..d368113 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,12 @@ public API, a minor version adds to it, and a patch version fixes it. of a rule against a customer or an instance. - `feature_flags.update(..., slug=...)` renames a flag. +### Changed + +- Unreleased AI helpers, meters and provider wrappers select the AI credit entitlement + with `entitlement=` rather than `pool=`; the old keyword is not accepted. + Low-level instance methods still use `entitlement_slug`, and HTTP routes are unchanged. + ### Fixed - `feature_flags.update` sends the flag's slug. Without it, Kaiten blanked the slug and the flag diff --git a/README.md b/README.md index 6133437..30f49fa 100644 --- a/README.md +++ b/README.md @@ -840,7 +840,7 @@ See the Kaiten trademark policy in the main Kaiten repository. Use `from kaitencloud.openai import OpenAI` or `from kaitencloud.anthropic import Anthropic`, then pass -`kaiten=client, instance="acme-prod", pool="chat-credits"` alongside your normal +`kaiten=client, instance="acme-prod", entitlement="chat-credits"` alongside your normal provider options. Async drop-in classes and `wrap_openai` / `wrap_anthropic` for existing clients are available. Install the corresponding `openai` or `anthropic` extra. Every allow-listed call reserves before execution and settles diff --git a/internal-docs/AI_WRAPPERS.md b/internal-docs/AI_WRAPPERS.md index d6177d8..f98db1e 100644 --- a/internal-docs/AI_WRAPPERS.md +++ b/internal-docs/AI_WRAPPERS.md @@ -12,14 +12,14 @@ Install `kaitencloud[openai]` (OpenAI 3.x, >=3.35.1) or from kaitencloud.openai import OpenAI provider = OpenAI( - api_key="your-provider-key", kaiten=kaiten, instance="acme-prod", pool="chat-credits" + api_key="your-provider-key", kaiten=kaiten, instance="acme-prod", entitlement="chat-credits" ) reply = provider.chat.completions.create(model="your-model", messages=messages) ``` `AsyncOpenAI`, `Anthropic` and `AsyncAnthropic` use the same keyword options. Provider and Kaiten clients must both be sync or both be async. An existing -client can be delegated with `wrap_openai(client, kaiten=..., instance=..., pool=...)` +client can be delegated with `wrap_openai(client, kaiten=..., instance=..., entitlement=...)` or `wrap_anthropic(...)`. The wrapper does not own a supplied client's lifetime; `close()` and context-manager exit delegate to that client. Copies made with `copy()` / `with_options()` retain the meter. diff --git a/src/kaitencloud/_async/ai.py b/src/kaitencloud/_async/ai.py index 57b50fe..6ac9ae5 100644 --- a/src/kaitencloud/_async/ai.py +++ b/src/kaitencloud/_async/ai.py @@ -49,17 +49,19 @@ def __init__( kaiten: AsyncKaitenClient, *, instance: str, - pool: str, + entitlement: str, estimator: Estimator | None = None, ) -> None: self.kaiten = kaiten self.instance = instance - self.pool = pool + self.entitlement = entitlement self.estimator: Estimator = estimator or default_estimator self.unsupported = False def __repr__(self) -> str: - return f"<{type(self).__name__} instance={self.instance!r} pool={self.pool!r}>" + return ( + f"<{type(self).__name__} instance={self.instance!r} entitlement={self.entitlement!r}>" + ) def estimate(self, request: AiRequest) -> AiUsageParam | None: """The usage to reserve for ``request``, from the meter's estimator.""" @@ -86,7 +88,7 @@ async def reserve( try: await self.kaiten.instances.reserve_ai_usage( self.instance, - self.pool, + self.entitlement, model=model, request_type=request_type, transaction_id=transaction_id, @@ -125,13 +127,13 @@ async def commit( "attempting settlement without usage", transaction_id, self.instance, - self.pool, + self.entitlement, ) usage = None try: return await self.kaiten.instances.commit_ai_usage( self.instance, - self.pool, + self.entitlement, transaction_id=transaction_id, request_status=request_status, usage=usage, @@ -146,7 +148,7 @@ async def commit( "reservation will expire and the call goes unbilled", transaction_id, self.instance, - self.pool, + self.entitlement, ) return None @@ -195,7 +197,7 @@ async def run( "attempting settlement without usage", reserved, self.instance, - self.pool, + self.entitlement, ) await self.commit(reserved, usage=actual_usage) return result @@ -206,7 +208,7 @@ def _disable(self, error: AiUsageUnsupportedError) -> None: "Kaiten AI usage is NOT being reported for %s/%s: %s. Calls go through " "unmetered until this client is recreated.", self.instance, - self.pool, + self.entitlement, error, ) self.unsupported = True @@ -217,7 +219,7 @@ async def async_run( fn: Callable[[], Awaitable[T]], *, instance: str, - pool: str, + entitlement: str, model: str, request_type: AiRequestType = "COMPLETION", estimate: AiUsageParam | None = None, @@ -226,7 +228,7 @@ async def async_run( ttl_seconds: int | None = None, ) -> T: """One metered call: see :meth:`AsyncMeter.run`.""" - meter = AsyncMeter(kaiten, instance=instance, pool=pool) + meter = AsyncMeter(kaiten, instance=instance, entitlement=entitlement) return await meter.run( fn, model=model, diff --git a/src/kaitencloud/_provider_clients.py b/src/kaitencloud/_provider_clients.py index 94f1e00..6eecbf6 100644 --- a/src/kaitencloud/_provider_clients.py +++ b/src/kaitencloud/_provider_clients.py @@ -23,15 +23,19 @@ def wrap( provider: str, *, instance: str, - pool: str, + entitlement: str, estimator: Estimator | None = None, ) -> Any: if isinstance(kaiten, AsyncKaitenClient): return AsyncProvider( - client, AsyncMeter(kaiten, instance=instance, pool=pool, estimator=estimator), provider + client, + AsyncMeter(kaiten, instance=instance, entitlement=entitlement, estimator=estimator), + provider, ) return Provider( - client, Meter(kaiten, instance=instance, pool=pool, estimator=estimator), provider + client, + Meter(kaiten, instance=instance, entitlement=entitlement, estimator=estimator), + provider, ) @@ -46,12 +50,14 @@ def init( *args: Any, kaiten: KaitenClient | AsyncKaitenClient | None = None, instance: str | None = None, - pool: str | None = None, + entitlement: str | None = None, estimator: Estimator | None = None, **kwargs: Any, ) -> None: - if kaiten is not None and (instance is None or pool is None): - raise ValueError("instance and pool are required with kaiten") + if "pool" in kwargs: + raise TypeError("unexpected keyword argument 'pool'; use 'entitlement' instead") + if kaiten is not None and (instance is None or entitlement is None): + raise ValueError("instance and entitlement are required with kaiten") if kaiten is not None and name.startswith("Async") != isinstance(kaiten, AsyncKaitenClient): raise TypeError( "provider and Kaiten clients must both be synchronous or both asynchronous" @@ -59,8 +65,15 @@ def init( base.__init__(self, *args, **kwargs) if kaiten is not None: assert instance is not None - assert pool is not None - proxy = wrap(self, kaiten, provider, instance=instance, pool=pool, estimator=estimator) + assert entitlement is not None + proxy = wrap( + self, + kaiten, + provider, + instance=instance, + entitlement=entitlement, + estimator=estimator, + ) roots = ( ("chat", "responses", "embeddings", "images", "audio") if provider == "openai" diff --git a/src/kaitencloud/_sync/ai.py b/src/kaitencloud/_sync/ai.py index b8dd6d0..39c6ef1 100644 --- a/src/kaitencloud/_sync/ai.py +++ b/src/kaitencloud/_sync/ai.py @@ -51,17 +51,19 @@ def __init__( kaiten: KaitenClient, *, instance: str, - pool: str, + entitlement: str, estimator: Estimator | None = None, ) -> None: self.kaiten = kaiten self.instance = instance - self.pool = pool + self.entitlement = entitlement self.estimator: Estimator = estimator or default_estimator self.unsupported = False def __repr__(self) -> str: - return f"<{type(self).__name__} instance={self.instance!r} pool={self.pool!r}>" + return ( + f"<{type(self).__name__} instance={self.instance!r} entitlement={self.entitlement!r}>" + ) def estimate(self, request: AiRequest) -> AiUsageParam | None: """The usage to reserve for ``request``, from the meter's estimator.""" @@ -88,7 +90,7 @@ def reserve( try: self.kaiten.instances.reserve_ai_usage( self.instance, - self.pool, + self.entitlement, model=model, request_type=request_type, transaction_id=transaction_id, @@ -127,13 +129,13 @@ def commit( "attempting settlement without usage", transaction_id, self.instance, - self.pool, + self.entitlement, ) usage = None try: return self.kaiten.instances.commit_ai_usage( self.instance, - self.pool, + self.entitlement, transaction_id=transaction_id, request_status=request_status, usage=usage, @@ -148,7 +150,7 @@ def commit( "reservation will expire and the call goes unbilled", transaction_id, self.instance, - self.pool, + self.entitlement, ) return None @@ -197,7 +199,7 @@ def run( "attempting settlement without usage", reserved, self.instance, - self.pool, + self.entitlement, ) self.commit(reserved, usage=actual_usage) return result @@ -208,7 +210,7 @@ def _disable(self, error: AiUsageUnsupportedError) -> None: "Kaiten AI usage is NOT being reported for %s/%s: %s. Calls go through " "unmetered until this client is recreated.", self.instance, - self.pool, + self.entitlement, error, ) self.unsupported = True @@ -219,7 +221,7 @@ def run( fn: Callable[[], T], *, instance: str, - pool: str, + entitlement: str, model: str, request_type: AiRequestType = "COMPLETION", estimate: AiUsageParam | None = None, @@ -228,7 +230,7 @@ def run( ttl_seconds: int | None = None, ) -> T: """One metered call: see :meth:`Meter.run`.""" - meter = Meter(kaiten, instance=instance, pool=pool) + meter = Meter(kaiten, instance=instance, entitlement=entitlement) return meter.run( fn, model=model, diff --git a/src/kaitencloud/ai.py b/src/kaitencloud/ai.py index 5a98e5c..e10542d 100644 --- a/src/kaitencloud/ai.py +++ b/src/kaitencloud/ai.py @@ -12,7 +12,7 @@ from kaitencloud.openai import OpenAI # instead of: from openai import OpenAI - openai = OpenAI(kaiten=kaiten, instance="acme-prod", pool="chat-credits") + openai = OpenAI(kaiten=kaiten, instance="acme-prod", entitlement="chat-credits") Anything else goes through :func:`run`, which reserves, calls, and commits:: @@ -22,7 +22,7 @@ kaiten, lambda: llm.generate(prompt), instance="acme-prod", - pool="chat-credits", + entitlement="chat-credits", model="gpt-4o", estimate={"input_tokens": 1200, "output_tokens": 800}, usage=lambda reply: ai.from_openai(reply.usage), @@ -101,7 +101,7 @@ def reserve( kaiten: KaitenClient, *, instance: str, - pool: str, + entitlement: str, model: str, request_type: AiRequestType = ..., usage: AiUsageParam | None = ..., @@ -113,7 +113,7 @@ def reserve( kaiten: AsyncKaitenClient, *, instance: str, - pool: str, + entitlement: str, model: str, request_type: AiRequestType = ..., usage: AiUsageParam | None = ..., @@ -124,7 +124,7 @@ def reserve( kaiten: KaitenClient | AsyncKaitenClient, *, instance: str, - pool: str, + entitlement: str, model: str, request_type: AiRequestType = "COMPLETION", usage: AiUsageParam | None = None, @@ -138,7 +138,7 @@ def reserve( """ return kaiten.instances.reserve_ai_usage( instance, - pool, + entitlement, model=model, request_type=request_type, usage=usage, @@ -152,7 +152,7 @@ def commit( kaiten: KaitenClient, *, instance: str, - pool: str, + entitlement: str, transaction_id: str, request_status: AiRequestStatus = ..., usage: AiUsageParam | None = ..., @@ -164,7 +164,7 @@ def commit( kaiten: AsyncKaitenClient, *, instance: str, - pool: str, + entitlement: str, transaction_id: str, request_status: AiRequestStatus = ..., usage: AiUsageParam | None = ..., @@ -175,7 +175,7 @@ def commit( kaiten: KaitenClient | AsyncKaitenClient, *, instance: str, - pool: str, + entitlement: str, transaction_id: str, request_status: AiRequestStatus = "SUCCESS", usage: AiUsageParam | None = None, @@ -188,7 +188,7 @@ def commit( """ return kaiten.instances.commit_ai_usage( instance, - pool, + entitlement, transaction_id=transaction_id, request_status=request_status, usage=usage, @@ -203,7 +203,7 @@ def run( fn: Callable[[], T], *, instance: str, - pool: str, + entitlement: str, model: str, request_type: AiRequestType = ..., estimate: AiUsageParam | None = ..., @@ -217,7 +217,7 @@ def run( fn: Callable[[], Awaitable[T]], *, instance: str, - pool: str, + entitlement: str, model: str, request_type: AiRequestType = ..., estimate: AiUsageParam | None = ..., @@ -230,7 +230,7 @@ def run( fn: Callable[[], Any], *, instance: str, - pool: str, + entitlement: str, model: str, request_type: AiRequestType = "COMPLETION", estimate: AiUsageParam | None = None, @@ -245,7 +245,7 @@ def run( function and ``run`` returns an awaitable. fn: The provider call, taking no argument. instance: The instance making the call. - pool: The AI credit pool it draws on. + entitlement: The slug of the AI credit entitlement it draws on. model: The model, as a catalogue slug or a provider id. request_type: What the call produces; ``"COMPLETION"`` by default. estimate: The usage to reserve. @@ -265,7 +265,7 @@ def run( """ options: dict[str, Any] = { "instance": instance, - "pool": pool, + "entitlement": entitlement, "model": model, "request_type": request_type, "estimate": estimate, diff --git a/src/kaitencloud/anthropic.py b/src/kaitencloud/anthropic.py index f6f4e7f..4037dd8 100644 --- a/src/kaitencloud/anthropic.py +++ b/src/kaitencloud/anthropic.py @@ -24,11 +24,13 @@ def wrap_anthropic( *, kaiten: KaitenClient | AsyncKaitenClient, instance: str, - pool: str, + entitlement: str, estimator: Estimator | None = None, ) -> Any: """Meter the allow-list on an existing client; unlisted methods stay provider-owned.""" - return wrap(client, kaiten, "anthropic", instance=instance, pool=pool, estimator=estimator) + return wrap( + client, kaiten, "anthropic", instance=instance, entitlement=entitlement, estimator=estimator + ) def __getattr__(name: str) -> Any: diff --git a/src/kaitencloud/openai.py b/src/kaitencloud/openai.py index 3396b08..e9e1a55 100644 --- a/src/kaitencloud/openai.py +++ b/src/kaitencloud/openai.py @@ -24,11 +24,13 @@ def wrap_openai( *, kaiten: KaitenClient | AsyncKaitenClient, instance: str, - pool: str, + entitlement: str, estimator: Estimator | None = None, ) -> Any: """Meter the allow-list on an existing client; unlisted methods stay provider-owned.""" - return wrap(client, kaiten, "openai", instance=instance, pool=pool, estimator=estimator) + return wrap( + client, kaiten, "openai", instance=instance, entitlement=entitlement, estimator=estimator + ) def __getattr__(name: str) -> Any: diff --git a/tests/test_ai.py b/tests/test_ai.py index b219ae0..76b158e 100644 --- a/tests/test_ai.py +++ b/tests/test_ai.py @@ -266,12 +266,12 @@ def test_the_client_retry_setting_does_not_change_the_ai_policy( def test_the_module_phases_go_through_the_client(kaiten: tuple[KaitenClient, FakeKaiten]) -> None: client, fake = kaiten - reserved = ai.reserve(client, instance=INSTANCE, pool=POOL, model="gpt-4o") + reserved = ai.reserve(client, instance=INSTANCE, entitlement=POOL, model="gpt-4o") assert reserved.ai is not None ai.commit( client, instance=INSTANCE, - pool=POOL, + entitlement=POOL, transaction_id=reserved.ai.transaction_id, request_status="NOT_SENT", ) @@ -280,6 +280,10 @@ def test_the_module_phases_go_through_the_client(kaiten: tuple[KaitenClient, Fak ("commit", reserved.ai.transaction_id), ] assert fake.commits[0]["requestStatus"] == "NOT_SENT" + assert all( + report.request.url.path == f"/api/instances/{INSTANCE}/entitlements/{POOL}/usage" + for report in fake.reports + ) def test_run_reserves_calls_and_commits_the_usage(kaiten: tuple[KaitenClient, FakeKaiten]) -> None: @@ -294,7 +298,7 @@ def call() -> dict[str, Any]: client, call, instance=INSTANCE, - pool=POOL, + entitlement=POOL, model="gpt-4o", estimate={"input_tokens": 100, "output_tokens": 1024}, usage=lambda reply: ai.from_openai(reply["usage"]), @@ -321,7 +325,9 @@ def test_run_does_not_call_the_provider_when_the_pool_is_empty( fake.refuse("reserve", problem(409, "ThresholdExceeded")) called: list[bool] = [] with pytest.raises(InsufficientCreditsError): - ai.run(client, lambda: called.append(True), instance=INSTANCE, pool=POOL, model="gpt-4o") + ai.run( + client, lambda: called.append(True), instance=INSTANCE, entitlement=POOL, model="gpt-4o" + ) assert called == [] assert fake.commits == [] @@ -359,7 +365,7 @@ def call() -> None: raise error with pytest.raises(type(error)): - ai.run(client, call, instance=INSTANCE, pool=POOL, model="gpt-4o") + ai.run(client, call, instance=INSTANCE, entitlement=POOL, model="gpt-4o") assert [commit["requestStatus"] for commit in fake.commits] == [expected] @@ -369,7 +375,9 @@ def test_run_survives_a_commit_that_keeps_failing( client, fake = kaiten fake.refuse("commit", *[status(503)] * 4) with caplog.at_level(logging.ERROR, logger="kaitencloud.ai"): - result = ai.run(client, lambda: "reply", instance=INSTANCE, pool=POOL, model="gpt-4o") + result = ai.run( + client, lambda: "reply", instance=INSTANCE, entitlement=POOL, model="gpt-4o" + ) assert result == "reply" assert len(fake.commits) == 4 assert "reservation will expire" in caplog.text @@ -405,7 +413,7 @@ async def call() -> Any: return reply return await ai.run( - client, call, instance=INSTANCE, pool=POOL, model="gpt-4o", usage=usage + client, call, instance=INSTANCE, entitlement=POOL, model="gpt-4o", usage=usage ) with caplog.at_level(logging.ERROR, logger="kaitencloud.ai"): @@ -418,7 +426,7 @@ async def call() -> Any: client, lambda: reply, instance=INSTANCE, - pool=POOL, + entitlement=POOL, model="gpt-4o", usage=usage, ) @@ -449,7 +457,9 @@ def test_run_lets_the_call_through_a_server_without_ai_credits( client, fake = kaiten fake.refuse("reserve", problem(422, None)) with caplog.at_level(logging.ERROR, logger="kaitencloud.ai"): - result = ai.run(client, lambda: "reply", instance=INSTANCE, pool=POOL, model="gpt-4o") + result = ai.run( + client, lambda: "reply", instance=INSTANCE, entitlement=POOL, model="gpt-4o" + ) assert result == "reply" assert fake.phases == [("reserve", fake.reports[0].transaction_id)] assert "NOT being reported" in caplog.text @@ -460,7 +470,7 @@ def test_a_meter_logs_an_unsupported_server_once_and_stops_reporting( ) -> None: client, fake = kaiten fake.refuse("reserve", problem(422, "AiUsageUnsupportedEntitlement")) - meter = ai.Meter(client, instance=INSTANCE, pool=POOL) + meter = ai.Meter(client, instance=INSTANCE, entitlement=POOL) with caplog.at_level(logging.ERROR, logger="kaitencloud.ai"): for _ in range(3): assert meter.run(lambda: "reply", model="gpt-4o") == "reply" @@ -478,16 +488,19 @@ async def scenario() -> str: async def call() -> str: return "reply" - reserved = await ai.reserve(client, instance=INSTANCE, pool=POOL, model="gpt-4o") + reserved = await ai.reserve(client, instance=INSTANCE, entitlement=POOL, model="gpt-4o") assert reserved.ai is not None await ai.commit( - client, instance=INSTANCE, pool=POOL, transaction_id=reserved.ai.transaction_id + client, + instance=INSTANCE, + entitlement=POOL, + transaction_id=reserved.ai.transaction_id, ) return await ai.run( client, call, instance=INSTANCE, - pool=POOL, + entitlement=POOL, model="gpt-4o", usage=lambda _: {"output_tokens": 3}, ) @@ -499,8 +512,52 @@ async def call() -> str: def test_an_async_client_is_metered_by_an_async_meter() -> None: client = AsyncKaitenClient(token="ksh_x", base_url="https://kaiten.test") - meter = ai.AsyncMeter(client, instance=INSTANCE, pool=POOL) - assert repr(meter) == "" + meter = ai.AsyncMeter(client, instance=INSTANCE, entitlement=POOL) + assert repr(meter) == "" + assert meter.entitlement == POOL + assert not hasattr(meter, "pool") + + +@pytest.mark.parametrize("asynchronous", [False, True]) +@pytest.mark.parametrize("entry_point", ["reserve", "commit", "run", "meter", "runner"]) +@pytest.mark.parametrize("include_entitlement", [False, True]) +def test_high_level_ai_rejects_the_old_pool_keyword_before_reporting( + make_client: Callable[..., Any], + make_async_client: Callable[..., Any], + asynchronous: bool, + entry_point: str, + include_entitlement: bool, +) -> None: + from kaitencloud._async.ai import async_run + from kaitencloud._sync.ai import run + + fake = FakeKaiten() + factory = make_async_client if asynchronous else make_client + client, _ = factory(fake) + options: dict[str, Any] = {"instance": INSTANCE, "pool": POOL} + if include_entitlement: + options["entitlement"] = POOL + args: tuple[Any, ...] = (client,) + if entry_point == "meter": + target = ai.AsyncMeter if asynchronous else ai.Meter + elif entry_point == "runner": + target = async_run if asynchronous else run + else: + target = getattr(ai, entry_point) + if entry_point in ("run", "runner"): + args += (lambda: pytest.fail("provider must not be called"),) + if entry_point == "commit": + options["transaction_id"] = "t1" + elif entry_point != "meter": + options["model"] = "gpt-4o" + + with pytest.raises(TypeError, match="pool"): + target(*args, **options) + assert fake.reports == [] + if asynchronous: + asyncio.run(client.close()) + else: + client.close() # -- transaction ids, normalisers, estimation ---------------------------------------------- @@ -648,6 +705,7 @@ def test_an_image_generation_reserves_its_images() -> None: (600.0, 600), (12.2, 13), (httpx.Timeout(30.0, connect=10.0), 30), + (httpx.Timeout(1.0, pool=90.0), 90), (httpx.Timeout(None), None), (7200, 3600), (None, None), diff --git a/tests/test_provider_compatibility.py b/tests/test_provider_compatibility.py index 03b632e..e100dbf 100644 --- a/tests/test_provider_compatibility.py +++ b/tests/test_provider_compatibility.py @@ -66,7 +66,7 @@ async def scenario() -> None: api_key="synthetic", kaiten=kaiten, instance="i", - pool="p", + entitlement="p", http_client=httpx2.AsyncClient(transport=httpx2.MockTransport(handler)), ) as client, ): @@ -86,7 +86,7 @@ async def scenario() -> None: api_key="synthetic", kaiten=kaiten, instance="i", - pool="p", + entitlement="p", http_client=httpx2.Client(transport=httpx2.MockTransport(handler)), ) as client, ): @@ -153,7 +153,7 @@ async def scenario() -> str: api_key="synthetic", kaiten=kaiten, instance="i", - pool="p", + entitlement="p", http_client=httpx2.AsyncClient(transport=httpx2.MockTransport(handler)), ) as client, client.messages.stream(model="m", max_tokens=3, messages=[]) as stream, @@ -172,7 +172,7 @@ async def scenario() -> str: api_key="synthetic", kaiten=kaiten, instance="i", - pool="p", + entitlement="p", http_client=httpx2.Client(transport=httpx2.MockTransport(handler)), ) as client, client.messages.stream(model="m", max_tokens=3, messages=[]) as stream, @@ -227,7 +227,7 @@ async def scenario() -> None: api_key="synthetic", kaiten=kaiten, instance="i", - pool="p", + entitlement="p", http_client=httpx2.AsyncClient(transport=httpx2.MockTransport(handler)), ) as client, ): @@ -246,7 +246,7 @@ async def scenario() -> None: api_key="synthetic", kaiten=kaiten, instance="i", - pool="p", + entitlement="p", http_client=httpx2.Client(transport=httpx2.MockTransport(handler)), ) as client, ): diff --git a/tests/test_providers.py b/tests/test_providers.py index 0c2b2c9..50ae91a 100644 --- a/tests/test_providers.py +++ b/tests/test_providers.py @@ -119,7 +119,7 @@ def test_provider_reserves_calls_and_commits_without_changing_the_result( async def scenario() -> Any: client, _ = make_async_client(fake) async with client: - wrapped = wrap(provider, kaiten=client, instance="i", pool="p") + wrapped = wrap(provider, kaiten=client, instance="i", entitlement="p") assert wrapped.unlisted is provider.unlisted method = ( wrapped.chat.completions.create @@ -133,7 +133,7 @@ async def scenario() -> Any: else: client, _ = make_client(fake) with client: - wrapped = wrap(provider, kaiten=client, instance="i", pool="p") + wrapped = wrap(provider, kaiten=client, instance="i", entitlement="p") assert wrapped.unlisted is provider.unlisted method = ( wrapped.chat.completions.create @@ -156,7 +156,7 @@ def test_refusal_prevents_the_provider_and_unsupported_degrades_once( client, _ = make_client(fake) provider = Provider("reply") with client: - wrapped = wrap_openai(provider, kaiten=client, instance="i", pool="p") + wrapped = wrap_openai(provider, kaiten=client, instance="i", entitlement="p") if status_code == 409: with pytest.raises(InsufficientCreditsError): wrapped.responses.create(model="m", input="x") @@ -202,7 +202,7 @@ def test_stream_completion_and_partial_close( async def scenario() -> None: client, _ = make_async_client(fake) async with client: - wrapped = wrap(provider, kaiten=client, instance="i", pool="p") + wrapped = wrap(provider, kaiten=client, instance="i", entitlement="p") if provider_name == "anthropic": async with wrapped.messages.stream(model="m", messages=[], max_tokens=5) as stream: if abandoned: @@ -227,7 +227,7 @@ async def scenario() -> None: else: client, _ = make_client(fake) with client: - wrapped = wrap(provider, kaiten=client, instance="i", pool="p") + wrapped = wrap(provider, kaiten=client, instance="i", entitlement="p") if provider_name == "anthropic": with wrapped.messages.stream(model="m", messages=[], max_tokens=5) as stream: next(stream) if abandoned else list(stream) @@ -257,29 +257,58 @@ def test_commit_failure_does_not_replace_success_and_copy_stays_wrapped( client, _ = make_client(fake) provider = Provider("reply") with client: - wrapped = wrap_openai(provider, kaiten=client, instance="i", pool="p") + wrapped = wrap_openai(provider, kaiten=client, instance="i", entitlement="p") assert wrapped.with_options(timeout=1).responses.create(model="m") == "reply" assert len(fake.commits) == 4 +@pytest.mark.parametrize("asynchronous", [False, True]) +@pytest.mark.parametrize("provider_name", ["openai", "anthropic"]) def test_drop_in_is_a_provider_subclass_and_uses_the_same_loop( - make_client: Callable[..., Any], monkeypatch: pytest.MonkeyPatch + make_client: Callable[..., Any], + make_async_client: Callable[..., Any], + monkeypatch: pytest.MonkeyPatch, + asynchronous: bool, + provider_name: str, ) -> None: from kaitencloud._provider_clients import drop_in - class OpenAI(Provider): + class Base(Provider): def __init__(self, **kwargs: Any) -> None: - super().__init__(SimpleNamespace(usage={"prompt_tokens": 1, "completion_tokens": 2})) + assert "entitlement" not in kwargs + super().__init__(SimpleNamespace(usage={"output_tokens": 2}), asynchronous) - monkeypatch.setitem(sys.modules, "openai", SimpleNamespace(OpenAI=OpenAI)) - client_type = drop_in("openai", "OpenAI", "kaitencloud.openai") + name = ("Async" if asynchronous else "") + ( + "OpenAI" if provider_name == "openai" else "Anthropic" + ) + monkeypatch.setitem(sys.modules, provider_name, SimpleNamespace(**{name: Base})) + client_type = drop_in(provider_name, name, f"kaitencloud.{provider_name}") fake = FakeKaiten() - client, _ = make_client(fake) - with client: - provider = client_type(kaiten=client, instance="i", pool="p") - assert isinstance(provider, OpenAI) - provider.responses.create(model="m") + + def call(client: Any) -> Any: + provider = client_type(kaiten=client, instance="i", entitlement="p") + assert isinstance(provider, Base) + method = ( + provider.responses.create if provider_name == "openai" else provider.messages.create + ) + return method(model="m") + + async def scenario() -> None: + client, _ = make_async_client(fake) + async with client: + await call(client) + + if asynchronous: + asyncio.run(scenario()) + else: + client, _ = make_client(fake) + with client: + call(client) assert len(fake.commits) == 1 + assert all( + report.request.url.path == "/api/instances/i/entitlements/p/usage" + for report in fake.reports + ) @pytest.mark.parametrize("asynchronous", [False, True]) @@ -310,7 +339,7 @@ def test_responses_terminal_event_settles_once( async def scenario() -> None: client, _ = make_async_client(fake) async with client: - wrapped = wrap_openai(provider, kaiten=client, instance="i", pool="p") + wrapped = wrap_openai(provider, kaiten=client, instance="i", entitlement="p") stream = await wrapped.responses.create(model="m", stream=True) async with stream: async for _ in stream: @@ -321,7 +350,7 @@ async def scenario() -> None: else: client, _ = make_client(fake) with client: - wrapped = wrap_openai(provider, kaiten=client, instance="i", pool="p") + wrapped = wrap_openai(provider, kaiten=client, instance="i", entitlement="p") with wrapped.responses.create(model="m", stream=True) as stream: list(stream) assert "stream_options" not in provider.calls[0] @@ -350,7 +379,7 @@ def test_remaining_openai_allow_list(make_client: Callable[..., Any], operation: result = SimpleNamespace(usage={"prompt_tokens": 3}) client, _ = make_client(fake) with client: - target = wrap_openai(Provider(result), kaiten=client, instance="i", pool="p") + target = wrap_openai(Provider(result), kaiten=client, instance="i", entitlement="p") for part in operation.split("."): target = getattr(target, part) assert target(model="m") is result @@ -374,7 +403,7 @@ async def scenario() -> None: client, _ = make_async_client(fake) async with client: stream = await wrap_openai( - provider, kaiten=client, instance="i", pool="p" + provider, kaiten=client, instance="i", entitlement="p" ).responses.create(model="m", stream=True) await stream.close() @@ -383,9 +412,9 @@ async def scenario() -> None: else: client, _ = make_client(fake) with client: - stream = wrap_openai(provider, kaiten=client, instance="i", pool="p").responses.create( - model="m", stream=True - ) + stream = wrap_openai( + provider, kaiten=client, instance="i", entitlement="p" + ).responses.create(model="m", stream=True) stream.close() assert fake.commits[0]["requestStatus"] == "NOT_SENT" @@ -413,7 +442,7 @@ async def scenario() -> None: client, _ = make_async_client(fake) async with client: stream = await wrap_openai( - provider, kaiten=client, instance="i", pool="p" + provider, kaiten=client, instance="i", entitlement="p" ).responses.create(model="m", stream=True) with pytest.raises(httpx.ReadError): await stream.__anext__() @@ -423,11 +452,56 @@ async def scenario() -> None: else: client, _ = make_client(fake) with client: - stream = wrap_openai(provider, kaiten=client, instance="i", pool="p").responses.create( - model="m", stream=True - ) + stream = wrap_openai( + provider, kaiten=client, instance="i", entitlement="p" + ).responses.create(model="m", stream=True) with pytest.raises(httpx.ReadError): next(stream) stream.close() assert len(fake.commits) == 1 assert fake.commits[0]["requestStatus"] == "PROVIDER_ERROR" + + +@pytest.mark.parametrize("asynchronous", [False, True]) +@pytest.mark.parametrize("provider_name", ["openai", "anthropic"]) +@pytest.mark.parametrize("drop_in_client", [False, True]) +@pytest.mark.parametrize("include_entitlement", [False, True]) +def test_provider_entry_points_reject_pool_before_constructing_or_calling_provider( + make_client: Callable[..., Any], + make_async_client: Callable[..., Any], + monkeypatch: pytest.MonkeyPatch, + asynchronous: bool, + provider_name: str, + drop_in_client: bool, + include_entitlement: bool, +) -> None: + from kaitencloud._provider_clients import drop_in + + fake = FakeKaiten() + factory = make_async_client if asynchronous else make_client + client, _ = factory(fake) + options: dict[str, Any] = {"kaiten": client, "instance": "i", "pool": "p"} + if include_entitlement: + options["entitlement"] = "p" + if drop_in_client: + name = ("Async" if asynchronous else "") + ( + "OpenAI" if provider_name == "openai" else "Anthropic" + ) + + class Base: + def __init__(self, **kwargs: Any) -> None: + pytest.fail("provider must not be constructed") + + monkeypatch.setitem(sys.modules, provider_name, SimpleNamespace(**{name: Base})) + target = drop_in(provider_name, name, f"kaitencloud.{provider_name}") + args = () + else: + target = wrap_openai if provider_name == "openai" else wrap_anthropic + args = (Provider("reply", asynchronous),) + with pytest.raises(TypeError, match="pool"): + target(*args, **options) + assert fake.reports == [] + if asynchronous: + asyncio.run(client.close()) + else: + client.close() From e2e2dcf775c107c8c94f02a4db8a9e32711f75da Mon Sep 17 00:00:00 2001 From: tomflenner Date: Sat, 24 Oct 2026 19:11:22 +0200 Subject: [PATCH 7/8] fix(ai): suppress numeric replay headers and validate exact usage Signed-off-by: tomflenner --- internal-docs/AI_WRAPPERS.md | 14 +++ src/kaitencloud/_ai.py | 9 +- src/kaitencloud/_async/_http.py | 8 ++ src/kaitencloud/_async/resources/instances.py | 6 +- src/kaitencloud/_sync/_http.py | 8 ++ src/kaitencloud/_sync/resources/instances.py | 6 +- tests/ai_fakes.py | 1 + tests/test_ai.py | 110 ++++++++++++++++-- tests/test_models.py | 11 ++ 9 files changed, 156 insertions(+), 17 deletions(-) diff --git a/internal-docs/AI_WRAPPERS.md b/internal-docs/AI_WRAPPERS.md index f98db1e..65dbe71 100644 --- a/internal-docs/AI_WRAPPERS.md +++ b/internal-docs/AI_WRAPPERS.md @@ -3,6 +3,20 @@ The server prices every call. The SDK sends only model, request type and usage; no prompts, responses or vendor prices are sent to Kaiten. +AI pools are `NUMBER_AI_CREDIT` entitlements with numeric `SUM` usage. Grants +and usage reads still use the `number` value shape; `ai_usage` is only the +reserve/commit calculation input. Credit adjustments/refunds use numeric +`report_usage(..., behavior="append")`, never `set`. + +Reserve and commit share `value.transactionId`; retries repeat the identical +phase/body. The server rejects `Idempotency-Key` on AI reports, so these phases +omit it case-insensitively, including SDK and supplied HTTP-client defaults; +numeric reports keep their configured headers. Decimal units accept integers, strings or `Decimal` +(12 integer digits and at most 6 decimal places), not binary floats. Conversion +rates and credit outcomes remain decimal strings. An omitted cache rate is +unknown policy, not an explicit zero/free rate; leave it omitted unless a rate +is known, and let the server determine pricing and warnings. + ## Provider clients Install `kaitencloud[openai]` (OpenAI 3.x, >=3.35.1) or diff --git a/src/kaitencloud/_ai.py b/src/kaitencloud/_ai.py index e281d13..3c4ba2c 100644 --- a/src/kaitencloud/_ai.py +++ b/src/kaitencloud/_ai.py @@ -75,7 +75,7 @@ def new_transaction_id() -> str: def check_transaction_id(value: str) -> str: """``value``, when the API accepts it as a transaction id; ``ValueError`` otherwise.""" - if not isinstance(value, str) or not _TRANSACTION_ID.match(value): + if not isinstance(value, str) or not _TRANSACTION_ID.fullmatch(value): raise ValueError( "a transaction id is 1 to 128 characters among letters, digits and . _ : -, " f"got {value!r}" @@ -113,7 +113,12 @@ def _decimal_string(value: object) -> str: raise ValueError(f"usage units must be a decimal number, got {value!r}") from error if not number.is_finite() or number < 0: raise ValueError(f"usage units must be zero or more, got {value!r}") - return format(number, "f") + text = format(number, "f") + if re.fullmatch(r"[0-9]{1,12}(\.[0-9]{1,6})?", text) is None: + raise ValueError( + "usage units must have 1 to 12 integer digits and at most 6 decimal places" + ) + return text def add_usage(*usages: AiUsageParam | None) -> AiUsageParam: diff --git a/src/kaitencloud/_async/_http.py b/src/kaitencloud/_async/_http.py index d1b3c6e..2d5fd23 100644 --- a/src/kaitencloud/_async/_http.py +++ b/src/kaitencloud/_async/_http.py @@ -99,6 +99,7 @@ async def request( idempotent: bool | None = None, errors: Mapping[int, type[APIStatusError]] | None = None, headers: Mapping[str, str] | None = None, + omit_headers: Collection[str] = (), max_retries: int | None = None, retry_statuses: Collection[int] | None = None, ) -> httpx.Response: @@ -111,6 +112,8 @@ async def request( only burns the rate budget. ``headers`` are sent on every attempt, so a key in them is the same on each retry. + ``omit_headers`` removes case-insensitive names after merging all defaults, including + those on a supplied HTTP client, without changing either client's configuration. ``max_retries`` and ``retry_statuses`` narrow the policy for one call: the AI usage phases retry a fixed number of times, on a fixed set of statuses. """ @@ -127,6 +130,9 @@ async def request( body=body, headers={**await self._headers(), **(headers or {})}, ) + for name in omit_headers: + if name in request.headers: + del request.headers[name] try: response = await self.http_client.send(request) except httpx.TransportError as error: @@ -185,6 +191,7 @@ async def send( idempotent: bool | None = None, errors: Mapping[int, type[APIStatusError]] | None = None, headers: Mapping[str, str] | None = None, + omit_headers: Collection[str] = (), max_retries: int | None = None, retry_statuses: Collection[int] | None = None, ) -> ModelT: @@ -196,6 +203,7 @@ async def send( idempotent=idempotent, errors=errors, headers=headers, + omit_headers=omit_headers, max_retries=max_retries, retry_statuses=retry_statuses, ) diff --git a/src/kaitencloud/_async/resources/instances.py b/src/kaitencloud/_async/resources/instances.py index 0719301..393e774 100644 --- a/src/kaitencloud/_async/resources/instances.py +++ b/src/kaitencloud/_async/resources/instances.py @@ -4,7 +4,6 @@ from __future__ import annotations import builtins -import uuid from collections.abc import Mapping from datetime import datetime from typing import Any @@ -311,7 +310,8 @@ async def reserve_ai_usage( AiUsageUnsupportedError: The deployment or the entitlement cannot take AI usage. Retried up to three times on a network error and on a 500, 502, 503 or 504, under one - ``Idempotency-Key`` and one transaction id: the API answers a repeat with the original. + transaction id and an identical phase/body: the API answers a repeat with the original. + AI reports must not send ``Idempotency-Key``; the server rejects that header. """ value = compact( { @@ -394,7 +394,7 @@ async def _report_ai_usage( AiEntitlementUsage, body=body, idempotent=True, - headers={"Idempotency-Key": str(uuid.uuid4())}, + omit_headers=("Idempotency-Key",), max_retries=AI_MAX_RETRIES, retry_statuses=AI_RETRY_STATUSES, ) diff --git a/src/kaitencloud/_sync/_http.py b/src/kaitencloud/_sync/_http.py index 7a45c70..7bce6cd 100644 --- a/src/kaitencloud/_sync/_http.py +++ b/src/kaitencloud/_sync/_http.py @@ -101,6 +101,7 @@ def request( idempotent: bool | None = None, errors: Mapping[int, type[APIStatusError]] | None = None, headers: Mapping[str, str] | None = None, + omit_headers: Collection[str] = (), max_retries: int | None = None, retry_statuses: Collection[int] | None = None, ) -> httpx.Response: @@ -113,6 +114,8 @@ def request( only burns the rate budget. ``headers`` are sent on every attempt, so a key in them is the same on each retry. + ``omit_headers`` removes case-insensitive names after merging all defaults, including + those on a supplied HTTP client, without changing either client's configuration. ``max_retries`` and ``retry_statuses`` narrow the policy for one call: the AI usage phases retry a fixed number of times, on a fixed set of statuses. """ @@ -129,6 +132,9 @@ def request( body=body, headers={**self._headers(), **(headers or {})}, ) + for name in omit_headers: + if name in request.headers: + del request.headers[name] try: response = self.http_client.send(request) except httpx.TransportError as error: @@ -187,6 +193,7 @@ def send( idempotent: bool | None = None, errors: Mapping[int, type[APIStatusError]] | None = None, headers: Mapping[str, str] | None = None, + omit_headers: Collection[str] = (), max_retries: int | None = None, retry_statuses: Collection[int] | None = None, ) -> ModelT: @@ -198,6 +205,7 @@ def send( idempotent=idempotent, errors=errors, headers=headers, + omit_headers=omit_headers, max_retries=max_retries, retry_statuses=retry_statuses, ) diff --git a/src/kaitencloud/_sync/resources/instances.py b/src/kaitencloud/_sync/resources/instances.py index 35394d9..e5c67fe 100644 --- a/src/kaitencloud/_sync/resources/instances.py +++ b/src/kaitencloud/_sync/resources/instances.py @@ -6,7 +6,6 @@ from __future__ import annotations import builtins -import uuid from collections.abc import Mapping from datetime import datetime from typing import Any @@ -313,7 +312,8 @@ def reserve_ai_usage( AiUsageUnsupportedError: The deployment or the entitlement cannot take AI usage. Retried up to three times on a network error and on a 500, 502, 503 or 504, under one - ``Idempotency-Key`` and one transaction id: the API answers a repeat with the original. + transaction id and an identical phase/body: the API answers a repeat with the original. + AI reports must not send ``Idempotency-Key``; the server rejects that header. """ value = compact( { @@ -396,7 +396,7 @@ def _report_ai_usage( AiEntitlementUsage, body=body, idempotent=True, - headers={"Idempotency-Key": str(uuid.uuid4())}, + omit_headers=("Idempotency-Key",), max_retries=AI_MAX_RETRIES, retry_statuses=AI_RETRY_STATUSES, ) diff --git a/tests/ai_fakes.py b/tests/ai_fakes.py index 7a15e2e..462d5ba 100644 --- a/tests/ai_fakes.py +++ b/tests/ai_fakes.py @@ -76,6 +76,7 @@ def __call__(self, request: httpx.Request) -> httpx.Response: body = json.loads(request.content) value = body["value"] assert value["type"] == "ai_usage", value + assert "idempotency-key" not in request.headers, "AI phases use value.transactionId" self.reports.append(Report(value, body, request)) queued = self.script.get(value["phase"]) if queued: diff --git a/tests/test_ai.py b/tests/test_ai.py index 76b158e..617cbd6 100644 --- a/tests/test_ai.py +++ b/tests/test_ai.py @@ -6,14 +6,16 @@ from __future__ import annotations import asyncio +import json import logging import uuid from collections.abc import Callable +from decimal import Decimal from typing import Any import httpx import pytest -from ai_fakes import FakeKaiten, network_error, problem, status +from ai_fakes import USAGE, FakeKaiten, network_error, problem, status from kaitencloud import ( AiUsageUnsupportedError, @@ -70,8 +72,7 @@ def test_a_reservation_sends_the_ai_variant_and_a_fresh_uuid7( } } assert uuid.UUID(report.transaction_id).version == 7 - assert report.key is not None - assert uuid.UUID(report.key).version == 4 + assert report.key is None assert answer.ai is not None assert answer.ai.transaction_id == report.transaction_id assert answer.ai.status == "RESERVED" @@ -80,6 +81,75 @@ def test_a_reservation_sends_the_ai_variant_and_a_fresh_uuid7( assert answer.value.type == "number" +@pytest.mark.usefixtures("delays") +@pytest.mark.parametrize("asynchronous", [False, True]) +@pytest.mark.parametrize("source", ["sdk", "httpx", "both"]) +def test_ai_phases_omit_inherited_keys_without_changing_numeric_reports( + asynchronous: bool, source: str +) -> None: + fake = FakeKaiten() + fake.refuse("reserve", status(503)) + fake.refuse("commit", status(503)) + numeric: list[httpx.Request] = [] + sdk_headers = {"iDeMpOtEnCy-KeY": "sdk-event"} if source != "httpx" else {} + http_headers = {"IDEMPOTENCY-KEY": "http-event"} if source != "sdk" else {} + http_headers["X-Request-Source"] = "billing" + expected_key = "http-event" if source == "httpx" else "sdk-event" + + def handler(request: httpx.Request) -> httpx.Response: + assert request.headers["x-request-source"] == "billing" + if json.loads(request.content)["value"]["type"] == "ai_usage": + return fake(request) + numeric.append(request) + return httpx.Response(200, json=USAGE) + + async def scenario() -> None: + async with ( + httpx.AsyncClient( + transport=httpx.MockTransport(handler), headers=http_headers + ) as http_client, + AsyncKaitenClient( + base_url="https://kaiten.test", + token="ksh_test", + default_headers=sdk_headers, + http_client=http_client, + ) as client, + ): + await client.instances.report_usage(INSTANCE, POOL, 1) + await client.instances.reserve_ai_usage( + INSTANCE, POOL, model="synthetic", transaction_id="call-1" + ) + await client.instances.commit_ai_usage(INSTANCE, POOL, transaction_id="call-1") + await client.instances.report_usage(INSTANCE, POOL, 1) + + if asynchronous: + asyncio.run(scenario()) + else: + with ( + httpx.Client( + transport=httpx.MockTransport(handler), headers=http_headers + ) as http_client, + KaitenClient( + base_url="https://kaiten.test", + token="ksh_test", + default_headers=sdk_headers, + http_client=http_client, + ) as client, + ): + client.instances.report_usage(INSTANCE, POOL, 1) + client.instances.reserve_ai_usage( + INSTANCE, POOL, model="synthetic", transaction_id="call-1" + ) + client.instances.commit_ai_usage(INSTANCE, POOL, transaction_id="call-1") + client.instances.report_usage(INSTANCE, POOL, 1) + + assert len(fake.reports) == 4 + assert all(report.key is None for report in fake.reports) + assert len(numeric) == 2 + assert all(request.headers["idempotency-key"] == expected_key for request in numeric) + assert sdk_headers == ({"iDeMpOtEnCy-KeY": "sdk-event"} if source != "httpx" else {}) + + def test_a_commit_sends_only_what_the_reservation_does_not_know( kaiten: tuple[KaitenClient, FakeKaiten], ) -> None: @@ -131,7 +201,7 @@ def test_a_commit_without_a_reservation_names_what_ran( } -@pytest.mark.parametrize("transaction_id", ["", "has space", "a" * 129, "slash/no"]) +@pytest.mark.parametrize("transaction_id", ["", "has space", "a" * 129, "slash/no", "valid\n"]) def test_a_transaction_id_the_api_would_refuse_is_refused_before_sending( kaiten: tuple[KaitenClient, FakeKaiten], transaction_id: str ) -> None: @@ -143,7 +213,16 @@ def test_a_transaction_id_the_api_would_refuse_is_refused_before_sending( @pytest.mark.parametrize( "usage", - [{"input_tokens": -1}, {"output_tokens": 1.5}, {"units": "-2"}, {"tokens": 3}], + [ + {"input_tokens": -1}, + {"output_tokens": 1.5}, + {"units": "-2"}, + {"units": "0.0000001"}, + {"units": "1000000000000"}, + {"units": 0.1}, + {"units": "NaN"}, + {"tokens": 3}, + ], ) def test_a_malformed_usage_is_refused_before_sending( kaiten: tuple[KaitenClient, FakeKaiten], usage: Any @@ -154,6 +233,15 @@ def test_a_malformed_usage_is_refused_before_sending( assert fake.reports == [] +@pytest.mark.parametrize("units", ["999999999999.123456", Decimal("0.000001"), 0]) +def test_units_keep_exact_contract_precision( + kaiten: tuple[KaitenClient, FakeKaiten], units: Any +) -> None: + client, fake = kaiten + client.instances.reserve_ai_usage(INSTANCE, POOL, model="gpt-4o", usage={"units": units}) + assert fake.reports[0].value["usage"]["units"] == format(Decimal(units), "f") + + # -- errors --------------------------------------------------------------------------------- @@ -213,7 +301,7 @@ def test_a_malformed_ai_body_is_not_mistaken_for_an_old_server( @pytest.mark.parametrize( "failure", [status(500), status(502), status(503), status(504), network_error] ) -def test_a_commit_is_retried_under_one_key_and_one_transaction_id( +def test_a_commit_is_retried_with_identical_body_and_no_idempotency_header( kaiten: tuple[KaitenClient, FakeKaiten], failure: Any ) -> None: client, fake = kaiten @@ -221,8 +309,9 @@ def test_a_commit_is_retried_under_one_key_and_one_transaction_id( client.instances.commit_ai_usage(INSTANCE, POOL, transaction_id="t1") assert len(fake.reports) == 3 - assert {report.key for report in fake.reports} == {fake.reports[0].key} + assert {report.key for report in fake.reports} == {None} assert {report.transaction_id for report in fake.reports} == {"t1"} + assert all(report.body == fake.reports[0].body for report in fake.reports) def test_a_commit_gives_up_after_three_retries(kaiten: tuple[KaitenClient, FakeKaiten]) -> None: @@ -244,11 +333,14 @@ def test_a_commit_is_not_retried_outside_its_statuses( assert len(fake.reports) == 1 -def test_each_logical_call_has_its_own_key(kaiten: tuple[KaitenClient, FakeKaiten]) -> None: +def test_phase_replays_do_not_generate_an_idempotency_header( + kaiten: tuple[KaitenClient, FakeKaiten], +) -> None: client, fake = kaiten client.instances.commit_ai_usage(INSTANCE, POOL, transaction_id="t1") client.instances.commit_ai_usage(INSTANCE, POOL, transaction_id="t1") - assert fake.reports[0].key != fake.reports[1].key + assert all(report.key is None for report in fake.reports) + assert fake.reports[0].body == fake.reports[1].body def test_the_client_retry_setting_does_not_change_the_ai_policy( diff --git a/tests/test_models.py b/tests/test_models.py index fef0e1a..a7fdaf7 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -156,6 +156,17 @@ def test_report_usage_union_is_generated_without_widening_entitlement_values() - assert AiEntitlementUsage is EntitlementUsage +def test_conversion_rates_preserve_exact_decimals_and_unknown_cache_policy() -> None: + from kaitencloud.types import ConversionRates + + rates = ConversionRates(per_input_token="0.000000000123456789") + assert rates.to_dict() == {"perInputToken": "0.000000000123456789"} + assert rates.per_cached_input_token is None + assert rates.per_cache_write_token is None + free_cache = ConversionRates(per_cached_input_token="0", per_cache_write_token="0") + assert free_cache.to_dict() == {"perCachedInputToken": "0", "perCacheWriteToken": "0"} + + def test_nullable_rule_request_type_reuses_the_generated_enum() -> None: from generate_models import ModelModule From 08651375b99eaf6012e1f908121f1b4d1d6b74a4 Mon Sep 17 00:00:00 2001 From: tomflenner Date: Sat, 24 Oct 2026 19:14:29 +0200 Subject: [PATCH 8/8] fix(ai): keep unknown Python validation failures actionable Signed-off-by: tomflenner --- src/kaitencloud/_exceptions.py | 16 ++++------------ tests/test_ai.py | 9 ++++++++- 2 files changed, 12 insertions(+), 13 deletions(-) diff --git a/src/kaitencloud/_exceptions.py b/src/kaitencloud/_exceptions.py index c185976..e931f6b 100644 --- a/src/kaitencloud/_exceptions.py +++ b/src/kaitencloud/_exceptions.py @@ -252,17 +252,9 @@ def make_status_error( return error_class(_message(response, problem), response=response, problem=problem) -# The 422 codes an AI usage report can answer with on a deployment that knows the variant. -# Any other 422 to such a report means the deployment does not. -_AI_INVALID_CODES = frozenset( - { - "TransactionIdRequired", - "InvalidTransactionId", - "InvalidAiUsage", - "AiUsageRequiresAppend", - "AiCreditPoolAppendOnly", - } -) +# Only known capability/legacy validation responses mean AI usage is unsupported. +# Unknown validation codes must remain caller errors, not enable unmetered fallback. +_AI_UNSUPPORTED_CODES = frozenset({"", "InvalidValueType", "AiUsageUnsupportedEntitlement"}) def make_ai_error(error: APIStatusError) -> APIStatusError: @@ -283,7 +275,7 @@ def make_ai_error(error: APIStatusError) -> APIStatusError: "UnknownModel": UnknownModelError, "ModelNotAuthorized": ModelNotAuthorizedError, }.get(reason) - if target is None and reason not in _AI_INVALID_CODES: + if target is None and reason in _AI_UNSUPPORTED_CODES: target = AiUsageUnsupportedError if target is None: return error diff --git a/tests/test_ai.py b/tests/test_ai.py index 617cbd6..498564f 100644 --- a/tests/test_ai.py +++ b/tests/test_ai.py @@ -283,7 +283,14 @@ def test_running_out_of_credits_is_also_a_reached_threshold( @pytest.mark.parametrize( - "code", ["InvalidAiUsage", "TransactionIdRequired", "AiUsageRequiresAppend"] + "code", + [ + "InvalidAiUsage", + "TransactionIdRequired", + "AiUsageRequiresAppend", + "AiIdempotencyKeyUnsupported", + "FutureValidationCode", + ], ) def test_a_malformed_ai_body_is_not_mistaken_for_an_old_server( kaiten: tuple[KaitenClient, FakeKaiten], code: str