pi-ai-python 1.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pi_agent/__init__.py +127 -0
- pi_agent/agent.py +617 -0
- pi_agent/agent_loop.py +820 -0
- pi_agent/proxy.py +269 -0
- pi_agent/py.typed +0 -0
- pi_agent/stream_fn.py +27 -0
- pi_agent/tools.py +327 -0
- pi_agent/types.py +394 -0
- pi_ai/__init__.py +263 -0
- pi_ai/abort.py +315 -0
- pi_ai/api/__init__.py +1 -0
- pi_ai/api/anthropic_messages.py +1782 -0
- pi_ai/api/azure_openai_config.py +138 -0
- pi_ai/api/azure_openai_responses.py +245 -0
- pi_ai/api/bedrock_converse_stream.py +1598 -0
- pi_ai/api/classifier_shared.py +211 -0
- pi_ai/api/cloudflare.py +36 -0
- pi_ai/api/cloudflare_workers_ai_system_one.py +80 -0
- pi_ai/api/constrained_sampling.py +279 -0
- pi_ai/api/github_copilot_headers.py +30 -0
- pi_ai/api/google_generative_ai.py +138 -0
- pi_ai/api/google_shared.py +927 -0
- pi_ai/api/google_vertex.py +234 -0
- pi_ai/api/lazy.py +156 -0
- pi_ai/api/llama_cpp_classify.py +449 -0
- pi_ai/api/mistral_conversations.py +756 -0
- pi_ai/api/openai_codex_responses.py +1545 -0
- pi_ai/api/openai_completions.py +1594 -0
- pi_ai/api/openai_decisions.py +210 -0
- pi_ai/api/openai_prompt_cache.py +19 -0
- pi_ai/api/openai_responses.py +418 -0
- pi_ai/api/openai_responses_shared.py +840 -0
- pi_ai/api/openai_sdk.py +204 -0
- pi_ai/api/openrouter_images.py +153 -0
- pi_ai/api/pi_messages.py +449 -0
- pi_ai/api/registry.py +103 -0
- pi_ai/api/simple_options.py +119 -0
- pi_ai/api/system_one_shared.py +148 -0
- pi_ai/api/transform_messages.py +211 -0
- pi_ai/api/typesafe_system_one.py +43 -0
- pi_ai/auth/__init__.py +1 -0
- pi_ai/auth/context.py +47 -0
- pi_ai/auth/credential_store.py +208 -0
- pi_ai/auth/helpers.py +123 -0
- pi_ai/auth/oauth/__init__.py +5 -0
- pi_ai/auth/oauth/_common.py +454 -0
- pi_ai/auth/oauth/anthropic.py +283 -0
- pi_ai/auth/oauth/callback_server.py +339 -0
- pi_ai/auth/oauth/device_code.py +127 -0
- pi_ai/auth/oauth/github_copilot.py +508 -0
- pi_ai/auth/oauth/kimi_coding.py +283 -0
- pi_ai/auth/oauth/load.py +64 -0
- pi_ai/auth/oauth/meta.py +238 -0
- pi_ai/auth/oauth/openai_chatgpt.py +321 -0
- pi_ai/auth/oauth/openai_codex.py +391 -0
- pi_ai/auth/oauth/openrouter.py +173 -0
- pi_ai/auth/oauth/pkce.py +23 -0
- pi_ai/auth/oauth/radius.py +311 -0
- pi_ai/auth/oauth/xai.py +220 -0
- pi_ai/auth/resolve.py +190 -0
- pi_ai/auth/types.py +292 -0
- pi_ai/cli.py +265 -0
- pi_ai/compat.py +651 -0
- pi_ai/env_api_keys.py +137 -0
- pi_ai/event_stream.py +329 -0
- pi_ai/models.py +1191 -0
- pi_ai/models_store.py +121 -0
- pi_ai/oauth.py +90 -0
- pi_ai/providers/__init__.py +1 -0
- pi_ai/providers/all.py +154 -0
- pi_ai/providers/amazon_bedrock.py +116 -0
- pi_ai/providers/ant_ling.py +23 -0
- pi_ai/providers/anthropic.py +105 -0
- pi_ai/providers/azure.py +103 -0
- pi_ai/providers/baseten.py +23 -0
- pi_ai/providers/catalog.py +167 -0
- pi_ai/providers/cerebras.py +23 -0
- pi_ai/providers/cloudflare_ai_gateway.py +30 -0
- pi_ai/providers/cloudflare_auth.py +112 -0
- pi_ai/providers/cloudflare_stream.py +84 -0
- pi_ai/providers/cloudflare_workers_ai.py +26 -0
- pi_ai/providers/data/amazon-bedrock.json +1 -0
- pi_ai/providers/data/ant-ling.json +1 -0
- pi_ai/providers/data/anthropic.json +1 -0
- pi_ai/providers/data/azure.json +1 -0
- pi_ai/providers/data/baseten.json +1 -0
- pi_ai/providers/data/cerebras.json +1 -0
- pi_ai/providers/data/cloudflare-ai-gateway.json +1 -0
- pi_ai/providers/data/cloudflare-workers-ai.json +1 -0
- pi_ai/providers/data/deepseek.json +1 -0
- pi_ai/providers/data/fireworks.json +1 -0
- pi_ai/providers/data/github-copilot.json +1 -0
- pi_ai/providers/data/google-vertex.json +1 -0
- pi_ai/providers/data/google.json +1 -0
- pi_ai/providers/data/groq.json +1 -0
- pi_ai/providers/data/huggingface.json +1 -0
- pi_ai/providers/data/kimi-coding.json +1 -0
- pi_ai/providers/data/manifest.json +1 -0
- pi_ai/providers/data/meta.json +1 -0
- pi_ai/providers/data/minimax-cn.json +1 -0
- pi_ai/providers/data/minimax.json +1 -0
- pi_ai/providers/data/mistral.json +1 -0
- pi_ai/providers/data/moonshotai-cn.json +1 -0
- pi_ai/providers/data/moonshotai.json +1 -0
- pi_ai/providers/data/nvidia.json +1 -0
- pi_ai/providers/data/openai-codex.json +1 -0
- pi_ai/providers/data/openai.json +1 -0
- pi_ai/providers/data/opencode-go.json +1 -0
- pi_ai/providers/data/opencode.json +1 -0
- pi_ai/providers/data/openrouter.json +1 -0
- pi_ai/providers/data/qwen-token-plan-cn.json +1 -0
- pi_ai/providers/data/qwen-token-plan-individual.json +1 -0
- pi_ai/providers/data/qwen-token-plan.json +1 -0
- pi_ai/providers/data/radius.json +1 -0
- pi_ai/providers/data/together.json +1 -0
- pi_ai/providers/data/typesafe.json +1 -0
- pi_ai/providers/data/vercel-ai-gateway.json +1 -0
- pi_ai/providers/data/xai.json +1 -0
- pi_ai/providers/data/xiaomi-token-plan-ams.json +1 -0
- pi_ai/providers/data/xiaomi-token-plan-cn.json +1 -0
- pi_ai/providers/data/xiaomi-token-plan-sgp.json +1 -0
- pi_ai/providers/data/xiaomi.json +1 -0
- pi_ai/providers/data/zai-coding-cn.json +1 -0
- pi_ai/providers/data/zai.json +1 -0
- pi_ai/providers/deepseek.py +23 -0
- pi_ai/providers/faux.py +680 -0
- pi_ai/providers/fireworks.py +26 -0
- pi_ai/providers/github_copilot.py +46 -0
- pi_ai/providers/google.py +23 -0
- pi_ai/providers/google_vertex.py +131 -0
- pi_ai/providers/groq.py +23 -0
- pi_ai/providers/huggingface.py +23 -0
- pi_ai/providers/kimi_coding.py +32 -0
- pi_ai/providers/meta.py +32 -0
- pi_ai/providers/minimax.py +23 -0
- pi_ai/providers/minimax_cn.py +23 -0
- pi_ai/providers/mistral.py +23 -0
- pi_ai/providers/moonshotai.py +23 -0
- pi_ai/providers/moonshotai_cn.py +23 -0
- pi_ai/providers/nvidia.py +23 -0
- pi_ai/providers/openai.py +44 -0
- pi_ai/providers/openai_codex.py +26 -0
- pi_ai/providers/opencode.py +36 -0
- pi_ai/providers/opencode_go.py +33 -0
- pi_ai/providers/opencode_headers.py +60 -0
- pi_ai/providers/openrouter.py +145 -0
- pi_ai/providers/openrouter_api.py +122 -0
- pi_ai/providers/qwen_token_plan.py +23 -0
- pi_ai/providers/qwen_token_plan_cn.py +23 -0
- pi_ai/providers/qwen_token_plan_individual.py +23 -0
- pi_ai/providers/radius.py +146 -0
- pi_ai/providers/radius_config.py +136 -0
- pi_ai/providers/together.py +23 -0
- pi_ai/providers/typesafe.py +22 -0
- pi_ai/providers/vercel_ai_gateway.py +25 -0
- pi_ai/providers/xai.py +32 -0
- pi_ai/providers/xiaomi.py +23 -0
- pi_ai/providers/xiaomi_token_plan_ams.py +23 -0
- pi_ai/providers/xiaomi_token_plan_cn.py +23 -0
- pi_ai/providers/xiaomi_token_plan_sgp.py +23 -0
- pi_ai/providers/zai.py +23 -0
- pi_ai/providers/zai_coding_cn.py +23 -0
- pi_ai/py.typed +0 -0
- pi_ai/serialization.py +477 -0
- pi_ai/session_resources.py +33 -0
- pi_ai/types.py +1003 -0
- pi_ai/utils/__init__.py +1 -0
- pi_ai/utils/callbacks.py +15 -0
- pi_ai/utils/diagnostics.py +70 -0
- pi_ai/utils/error_body.py +89 -0
- pi_ai/utils/estimate.py +125 -0
- pi_ai/utils/fastjson.py +63 -0
- pi_ai/utils/http.py +409 -0
- pi_ai/utils/json_parse.py +424 -0
- pi_ai/utils/misc.py +193 -0
- pi_ai/utils/model_operations.py +73 -0
- pi_ai/utils/models_error.py +35 -0
- pi_ai/utils/oauth_page.py +121 -0
- pi_ai/utils/overflow.py +87 -0
- pi_ai/utils/retry.py +193 -0
- pi_ai/utils/schema.py +283 -0
- pi_ai/utils/text.py +65 -0
- pi_ai/utils/transcript.py +271 -0
- pi_ai/utils/validation.py +813 -0
- pi_ai_python-1.3.0.dist-info/METADATA +167 -0
- pi_ai_python-1.3.0.dist-info/RECORD +189 -0
- pi_ai_python-1.3.0.dist-info/WHEEL +4 -0
- pi_ai_python-1.3.0.dist-info/entry_points.txt +2 -0
- pi_ai_python-1.3.0.dist-info/licenses/LICENSE +22 -0
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"""OpenAI's Decisions API: ``POST /v1/decisions`` with ``{model, input, questions}``.
|
|
2
|
+
|
|
3
|
+
https://developers.openai.com/api/docs/guides/decisions
|
|
4
|
+
|
|
5
|
+
The state is sent as JSON text. With images, the input becomes one user message
|
|
6
|
+
with the state as ``input_text`` followed by ``input_image`` data URLs. Questions
|
|
7
|
+
map to Decisions types: ``choice`` to ``choice``, ``score`` to ``score``, and
|
|
8
|
+
``bool`` to ``predicate``. Predicates have no criteria field, so the meanings of
|
|
9
|
+
true and false are appended to the instructions.
|
|
10
|
+
|
|
11
|
+
Only OpenAI API keys work: Sign in with ChatGPT tokens are rejected on this route.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
from collections.abc import Mapping
|
|
18
|
+
from typing import Any
|
|
19
|
+
from urllib.parse import urljoin
|
|
20
|
+
|
|
21
|
+
from ..types import (
|
|
22
|
+
ClassifierAnswer,
|
|
23
|
+
ClassifierBoolAnswer,
|
|
24
|
+
ClassifierBoolQuestion,
|
|
25
|
+
ClassifierChoiceAnswer,
|
|
26
|
+
ClassifierContext,
|
|
27
|
+
ClassifierModel,
|
|
28
|
+
ClassifierQuestion,
|
|
29
|
+
ClassifierResult,
|
|
30
|
+
ClassifierScoreAnswer,
|
|
31
|
+
now_ms,
|
|
32
|
+
)
|
|
33
|
+
from ..utils.error_body import format_provider_error, normalize_provider_error
|
|
34
|
+
from ..utils.model_operations import classifier_error_result
|
|
35
|
+
from .classifier_shared import (
|
|
36
|
+
classifier_questions,
|
|
37
|
+
is_record,
|
|
38
|
+
parse_classifier_usage,
|
|
39
|
+
post_classifier_request,
|
|
40
|
+
required_number,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
__all__ = ["classify"]
|
|
44
|
+
|
|
45
|
+
LABEL = "OpenAI Decisions"
|
|
46
|
+
|
|
47
|
+
#: The endpoint accepts at most this many image parts per request.
|
|
48
|
+
MAX_IMAGES = 128
|
|
49
|
+
|
|
50
|
+
#: Cloudflare in front of api.openai.com answers 504 with an HTML page when a request runs
|
|
51
|
+
#: longer than about five seconds. Large inputs, currently above roughly 600K tokens, hit
|
|
52
|
+
#: this limit, and retrying the same input runs into it again, so 504 is not retried.
|
|
53
|
+
NO_RETRY_STATUSES = (504,)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _predicate_instructions(question: ClassifierBoolQuestion) -> str:
|
|
57
|
+
meanings = [
|
|
58
|
+
f"True means: {question.criteria['true']}" if question.criteria.get("true") else "",
|
|
59
|
+
f"False means: {question.criteria['false']}" if question.criteria.get("false") else "",
|
|
60
|
+
]
|
|
61
|
+
meanings = [meaning for meaning in meanings if meaning]
|
|
62
|
+
return question.instructions + "\n\n" + "\n".join(meanings) if meanings else question.instructions
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _wire_question(name: str, question: ClassifierQuestion) -> dict[str, Any]:
|
|
66
|
+
if question.type == "choice":
|
|
67
|
+
return {
|
|
68
|
+
"type": "choice",
|
|
69
|
+
"name": name,
|
|
70
|
+
"instructions": question.instructions,
|
|
71
|
+
"choices": [
|
|
72
|
+
{"value": value, "description": description} if description else {"value": value}
|
|
73
|
+
for value, description in question.criteria.items()
|
|
74
|
+
],
|
|
75
|
+
}
|
|
76
|
+
if question.type == "score":
|
|
77
|
+
return {
|
|
78
|
+
"type": "score",
|
|
79
|
+
"name": name,
|
|
80
|
+
"instructions": question.instructions,
|
|
81
|
+
"levels": [{"label": label} for label in question.criteria],
|
|
82
|
+
}
|
|
83
|
+
return {"type": "predicate", "name": name, "instructions": _predicate_instructions(question)}
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _wire_input(context: ClassifierContext) -> Any:
|
|
87
|
+
state = json.dumps(context.state, ensure_ascii=False, separators=(",", ":"))
|
|
88
|
+
images = context.images or []
|
|
89
|
+
if not images:
|
|
90
|
+
return state
|
|
91
|
+
if len(images) > MAX_IMAGES:
|
|
92
|
+
raise ValueError(f"{LABEL} accepts at most {MAX_IMAGES} images, got {len(images)}")
|
|
93
|
+
return [
|
|
94
|
+
{
|
|
95
|
+
"role": "user",
|
|
96
|
+
"content": [
|
|
97
|
+
{"type": "input_text", "text": state},
|
|
98
|
+
*(
|
|
99
|
+
{"type": "input_image", "image_url": f"data:{image.mime_type};base64,{image.data}"}
|
|
100
|
+
for image in images
|
|
101
|
+
),
|
|
102
|
+
],
|
|
103
|
+
}
|
|
104
|
+
]
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _choice_probabilities(value: Any, question_id: str) -> dict[str, float]:
|
|
108
|
+
if not isinstance(value, list):
|
|
109
|
+
raise ValueError(f"{LABEL} returned invalid probabilities for {question_id}")
|
|
110
|
+
probabilities: dict[str, float] = {}
|
|
111
|
+
for entry in value:
|
|
112
|
+
if not is_record(entry) or not isinstance(entry.get("value"), str):
|
|
113
|
+
raise ValueError(f"{LABEL} returned invalid probabilities for {question_id}")
|
|
114
|
+
choice = entry["value"]
|
|
115
|
+
probabilities[choice] = required_number(
|
|
116
|
+
LABEL, entry.get("probability"), f"probability for {question_id}.{choice}"
|
|
117
|
+
)
|
|
118
|
+
return probabilities
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _parse_answer(question_id: str, question: ClassifierQuestion, answer: Mapping[str, Any]) -> ClassifierAnswer:
|
|
122
|
+
kind = answer.get("type")
|
|
123
|
+
if kind == "refusal":
|
|
124
|
+
raise ValueError(f"{LABEL} refused to answer {question_id}")
|
|
125
|
+
if question.type == "choice":
|
|
126
|
+
choice = answer.get("choice")
|
|
127
|
+
if kind != "choice" or not isinstance(choice, str):
|
|
128
|
+
raise ValueError(f"{LABEL} did not return a choice answer for {question_id}")
|
|
129
|
+
return ClassifierChoiceAnswer(
|
|
130
|
+
choice=choice,
|
|
131
|
+
probabilities=_choice_probabilities(answer.get("probabilities"), question_id),
|
|
132
|
+
confidence=required_number(LABEL, answer.get("confidence"), f"confidence for {question_id}"),
|
|
133
|
+
)
|
|
134
|
+
if question.type == "score":
|
|
135
|
+
if kind != "score":
|
|
136
|
+
raise ValueError(f"{LABEL} did not return a score answer for {question_id}")
|
|
137
|
+
return ClassifierScoreAnswer(
|
|
138
|
+
score=required_number(LABEL, answer.get("score"), f"score for {question_id}"),
|
|
139
|
+
confidence=required_number(LABEL, answer.get("confidence"), f"confidence for {question_id}"),
|
|
140
|
+
)
|
|
141
|
+
if kind != "predicate":
|
|
142
|
+
raise ValueError(f"{LABEL} did not return a predicate answer for {question_id}")
|
|
143
|
+
return ClassifierBoolAnswer(
|
|
144
|
+
probability=required_number(LABEL, answer.get("probability"), f"probability for {question_id}")
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _parse_answers(value: Any, questions: Mapping[str, ClassifierQuestion]) -> dict[str, ClassifierAnswer]:
|
|
149
|
+
if not isinstance(value, list):
|
|
150
|
+
raise ValueError(f"{LABEL} returned an unexpected response")
|
|
151
|
+
by_name: dict[str, Mapping[str, Any]] = {}
|
|
152
|
+
for answer in value:
|
|
153
|
+
if is_record(answer) and isinstance(answer.get("name"), str):
|
|
154
|
+
by_name[answer["name"]] = answer
|
|
155
|
+
answers: dict[str, ClassifierAnswer] = {}
|
|
156
|
+
for question_id, question in questions.items():
|
|
157
|
+
answer = by_name.get(question_id)
|
|
158
|
+
if answer is None:
|
|
159
|
+
raise ValueError(f"{LABEL} did not return an answer for {question_id}")
|
|
160
|
+
answers[question_id] = _parse_answer(question_id, question, answer)
|
|
161
|
+
return answers
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _error_message(error: Exception) -> str:
|
|
165
|
+
if getattr(error, "status", None) == 504:
|
|
166
|
+
return (
|
|
167
|
+
f"{LABEL} error (504): the request timed out at the gateway. Very large inputs "
|
|
168
|
+
"(above roughly 600K tokens) currently exceed its time limit."
|
|
169
|
+
)
|
|
170
|
+
return format_provider_error(normalize_provider_error(error), f"{LABEL} error")
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
async def classify(
|
|
174
|
+
model: ClassifierModel, context: ClassifierContext, options: Mapping[str, Any] | None = None
|
|
175
|
+
) -> ClassifierResult:
|
|
176
|
+
"""Classification through OpenAI's Decisions API. Never raises."""
|
|
177
|
+
options = options or {}
|
|
178
|
+
output = ClassifierResult(
|
|
179
|
+
api=model.api, provider=model.provider, model=model.id, answers={}, stop_reason="stop", timestamp=now_ms()
|
|
180
|
+
)
|
|
181
|
+
try:
|
|
182
|
+
if model.api != "openai-decisions":
|
|
183
|
+
raise ValueError(f"Unsupported classifier API: {model.api}")
|
|
184
|
+
questions = classifier_questions(context)
|
|
185
|
+
body = await post_classifier_request(
|
|
186
|
+
LABEL,
|
|
187
|
+
urljoin(f"{model.base_url.rstrip('/')}/", "decisions"),
|
|
188
|
+
model,
|
|
189
|
+
{
|
|
190
|
+
"model": model.id,
|
|
191
|
+
"input": _wire_input(context),
|
|
192
|
+
"questions": [_wire_question(name, question) for name, question in questions.items()],
|
|
193
|
+
},
|
|
194
|
+
options,
|
|
195
|
+
NO_RETRY_STATUSES,
|
|
196
|
+
)
|
|
197
|
+
if not is_record(body):
|
|
198
|
+
raise ValueError(f"{LABEL} returned an unexpected response")
|
|
199
|
+
# Set before parsing answers: a request with malformed or refused answers was still billed.
|
|
200
|
+
usage = parse_classifier_usage(body.get("usage"), model)
|
|
201
|
+
if usage is not None:
|
|
202
|
+
output.usage = usage
|
|
203
|
+
output.answers = _parse_answers(body.get("answers"), questions)
|
|
204
|
+
return output
|
|
205
|
+
except Exception as error:
|
|
206
|
+
signal = options.get("signal")
|
|
207
|
+
failed = classifier_error_result(model, _error_message(error), aborted=signal is not None and signal.aborted)
|
|
208
|
+
failed.usage = output.usage
|
|
209
|
+
failed.timestamp = output.timestamp
|
|
210
|
+
return failed
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""OpenAI prompt-cache key helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = ["OPENAI_PROMPT_CACHE_KEY_MAX_LENGTH", "clamp_openai_prompt_cache_key"]
|
|
6
|
+
|
|
7
|
+
OPENAI_PROMPT_CACHE_KEY_MAX_LENGTH = 64
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def clamp_openai_prompt_cache_key(key: str | None) -> str | None:
|
|
11
|
+
"""Truncate a prompt cache key to OpenAI's limit, counting code points.
|
|
12
|
+
|
|
13
|
+
JavaScript's ``Array.from`` counts code points too, so keys match the TypeScript port.
|
|
14
|
+
"""
|
|
15
|
+
if key is None:
|
|
16
|
+
return None
|
|
17
|
+
if len(key) <= OPENAI_PROMPT_CACHE_KEY_MAX_LENGTH:
|
|
18
|
+
return key
|
|
19
|
+
return key[:OPENAI_PROMPT_CACHE_KEY_MAX_LENGTH]
|
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
# pyright: reportAttributeAccessIssue=false, reportArgumentType=false
|
|
2
|
+
# Converters branch on ``role``/``type`` discriminators held in locals, which pyright does not narrow.
|
|
3
|
+
"""OpenAI Responses API (``openai-responses``).
|
|
4
|
+
|
|
5
|
+
Streams ``responses.create`` through the official ``openai`` SDK (``AsyncOpenAI``).
|
|
6
|
+
Also used by OpenAI-compatible Responses endpoints (xAI, GitHub Copilot, OpenRouter,
|
|
7
|
+
OpenCode, Cloudflare AI Gateway, ...).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from collections.abc import Mapping
|
|
13
|
+
from typing import Any, Literal
|
|
14
|
+
|
|
15
|
+
from ..abort import abort_scope
|
|
16
|
+
from ..event_stream import AssistantMessageEventStream
|
|
17
|
+
from ..models import clamp_thinking_level
|
|
18
|
+
from ..types import (
|
|
19
|
+
AssistantMessage,
|
|
20
|
+
DoneEvent,
|
|
21
|
+
ErrorEvent,
|
|
22
|
+
Model,
|
|
23
|
+
ProviderResponse,
|
|
24
|
+
StartEvent,
|
|
25
|
+
StreamOptions,
|
|
26
|
+
TranscriptContext,
|
|
27
|
+
Usage,
|
|
28
|
+
)
|
|
29
|
+
from ..utils.callbacks import maybe_await
|
|
30
|
+
from ..utils.error_body import format_provider_error, normalize_provider_error
|
|
31
|
+
from ..utils.misc import get_pi_user_agent, get_provider_env_value, headers_to_record
|
|
32
|
+
from ..utils.transcript import get_declared_tools, resolve_transcript, resolve_transcript_tools
|
|
33
|
+
from .constrained_sampling import create_grammar_tool_input_properties
|
|
34
|
+
from .github_copilot_headers import build_copilot_dynamic_headers, has_copilot_vision_input
|
|
35
|
+
from .openai_prompt_cache import clamp_openai_prompt_cache_key
|
|
36
|
+
from .openai_responses_shared import convert_responses_messages, convert_responses_tools, process_responses_stream
|
|
37
|
+
from .openai_sdk import (
|
|
38
|
+
create_openai_client,
|
|
39
|
+
iter_sdk_stream,
|
|
40
|
+
merge_sdk_headers,
|
|
41
|
+
open_sdk_stream,
|
|
42
|
+
openai_request,
|
|
43
|
+
sdk_body_arguments,
|
|
44
|
+
)
|
|
45
|
+
from .simple_options import build_base_options, resolve_sampling_params
|
|
46
|
+
|
|
47
|
+
__all__ = [
|
|
48
|
+
"OpenAIResponsesOptions",
|
|
49
|
+
"OPENAI_TOOL_CALL_PROVIDERS",
|
|
50
|
+
"stream",
|
|
51
|
+
"stream_simple",
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
OPENAI_TOOL_CALL_PROVIDERS = frozenset({"openai", "openai-codex", "opencode"})
|
|
55
|
+
# OpenAI Responses rejects max_output_tokens below 16: https://github.com/earendil-works/pi/issues/6265
|
|
56
|
+
OPENAI_RESPONSES_MIN_OUTPUT_TOKENS = 16
|
|
57
|
+
CHATGPT_USAGE_URL = "https://chatgpt.com/settings/usage"
|
|
58
|
+
# Fixed reasoning.effort while configuration_update items carry the thinking level.
|
|
59
|
+
CONFIGURATION_UPDATE_REQUEST_EFFORT = "medium"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class OpenAIResponsesOptions(StreamOptions, total=False):
|
|
63
|
+
reasoning_effort: Literal["minimal", "low", "medium", "high", "xhigh", "max"]
|
|
64
|
+
reasoning_summary: Literal["auto", "detailed", "concise"] | None
|
|
65
|
+
service_tier: str
|
|
66
|
+
tool_choice: Any
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _is_chatgpt_sign_in(model: Model, api_key: str | None) -> bool:
|
|
70
|
+
"""OpenAI API keys start with ``sk-``; another credential sent directly to OpenAI is a
|
|
71
|
+
Sign in with ChatGPT access token."""
|
|
72
|
+
return (
|
|
73
|
+
model.provider == "openai"
|
|
74
|
+
and model.base_url == "https://api.openai.com/v1"
|
|
75
|
+
and api_key is not None
|
|
76
|
+
and not api_key.startswith("sk-")
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _has_header(headers: Mapping[str, str | None] | None, name: str) -> bool:
|
|
81
|
+
if not headers:
|
|
82
|
+
return False
|
|
83
|
+
expected = name.lower()
|
|
84
|
+
return any(key.lower() == expected and value is not None and value.strip() != "" for key, value in headers.items())
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _get_client_api_key(provider: str, api_key: str | None, headers: Mapping[str, str | None] | None) -> str:
|
|
88
|
+
if api_key:
|
|
89
|
+
return api_key
|
|
90
|
+
if _has_header(headers, "authorization") or _has_header(headers, "cf-aig-authorization"):
|
|
91
|
+
return "unused"
|
|
92
|
+
raise RuntimeError(f"No API key for provider: {provider}")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _detect_session_affinity_format(model: Model) -> str:
|
|
96
|
+
return "openrouter" if model.provider == "openrouter" or "openrouter.ai" in model.base_url else "openai"
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _resolve_cache_retention(cache_retention: str | None, env: Mapping[str, str] | None) -> str:
|
|
100
|
+
"""Default ``"short"``; ``PI_CACHE_RETENTION=long`` is honored for backward compatibility."""
|
|
101
|
+
if cache_retention:
|
|
102
|
+
return cache_retention
|
|
103
|
+
if get_provider_env_value("PI_CACHE_RETENTION", env) == "long":
|
|
104
|
+
return "long"
|
|
105
|
+
return "short"
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _compat_value(compat: Mapping[str, Any], key: str, default: Any) -> Any:
|
|
109
|
+
value = compat.get(key)
|
|
110
|
+
return default if value is None else value
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _get_compat(model: Model) -> dict[str, Any]:
|
|
114
|
+
compat = model.compat or {}
|
|
115
|
+
return {
|
|
116
|
+
"supportsDeveloperRole": _compat_value(compat, "supportsDeveloperRole", True),
|
|
117
|
+
"supportsMidConvoSystemMessages": _compat_value(compat, "supportsMidConvoSystemMessages", False),
|
|
118
|
+
"sessionAffinityFormat": _compat_value(compat, "sessionAffinityFormat", None)
|
|
119
|
+
or _detect_session_affinity_format(model),
|
|
120
|
+
"supportsLongCacheRetention": _compat_value(compat, "supportsLongCacheRetention", True),
|
|
121
|
+
"supportsStrictMode": _compat_value(compat, "supportsStrictMode", False),
|
|
122
|
+
"supportsOpenAIGrammarTools": _compat_value(compat, "supportsOpenAIGrammarTools", False),
|
|
123
|
+
"supportsAdditionalTools": _compat_value(compat, "supportsAdditionalTools", False),
|
|
124
|
+
"supportsToolSearch": _compat_value(compat, "supportsToolSearch", False),
|
|
125
|
+
"supportsExplicitPromptCacheMode": _compat_value(compat, "supportsExplicitPromptCacheMode", False),
|
|
126
|
+
"supportsMaxOutputTokens": _compat_value(compat, "supportsMaxOutputTokens", True),
|
|
127
|
+
"supportsMidConvoEffort": _compat_value(compat, "supportsMidConvoEffort", False),
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _map_thinking_level(model: Model, level: str) -> str:
|
|
132
|
+
level_map = model.thinking_level_map or {}
|
|
133
|
+
mapped = level_map.get(level)
|
|
134
|
+
return mapped if mapped is not None else level
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _get_configuration_update_effort(model: Model, options: Mapping[str, Any], compat: Mapping[str, Any]) -> str | None:
|
|
138
|
+
"""Effort to send in ``configuration_update`` items, or ``None`` to send it as ``reasoning.effort``."""
|
|
139
|
+
reasoning_effort = options.get("reasoning_effort")
|
|
140
|
+
if (
|
|
141
|
+
not compat["supportsMidConvoEffort"]
|
|
142
|
+
or not reasoning_effort
|
|
143
|
+
# Sign in with ChatGPT rejects configuration_update items.
|
|
144
|
+
or _is_chatgpt_sign_in(model, options.get("api_key"))
|
|
145
|
+
):
|
|
146
|
+
return None
|
|
147
|
+
return _map_thinking_level(model, reasoning_effort)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _get_prompt_cache_retention(compat: Mapping[str, Any], cache_retention: str) -> str | None:
|
|
151
|
+
if (
|
|
152
|
+
cache_retention == "long"
|
|
153
|
+
and compat["supportsLongCacheRetention"]
|
|
154
|
+
and not compat["supportsExplicitPromptCacheMode"]
|
|
155
|
+
):
|
|
156
|
+
return "24h"
|
|
157
|
+
return None
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _get_prompt_cache_options(compat: Mapping[str, Any], cache_retention: str) -> dict[str, Any] | None:
|
|
161
|
+
if not compat["supportsExplicitPromptCacheMode"]:
|
|
162
|
+
return None
|
|
163
|
+
if cache_retention == "none":
|
|
164
|
+
return {"mode": "explicit"}
|
|
165
|
+
if cache_retention == "long" and compat["supportsLongCacheRetention"]:
|
|
166
|
+
return {"ttl": "30m"}
|
|
167
|
+
return None
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def stream(
|
|
171
|
+
model: Model, context: TranscriptContext, options: Mapping[str, Any] | None = None
|
|
172
|
+
) -> AssistantMessageEventStream:
|
|
173
|
+
"""Stream a response from the OpenAI Responses API."""
|
|
174
|
+
opts: dict[str, Any] = dict(options or {})
|
|
175
|
+
out = AssistantMessageEventStream()
|
|
176
|
+
normalized_context = resolve_transcript(context, _get_compat(model)["supportsMidConvoSystemMessages"])
|
|
177
|
+
out.start(_run(model, normalized_context, opts, out))
|
|
178
|
+
return out
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
async def _run(
|
|
182
|
+
model: Model, context: TranscriptContext, options: dict[str, Any], out: AssistantMessageEventStream
|
|
183
|
+
) -> None:
|
|
184
|
+
compat = _get_compat(model)
|
|
185
|
+
configuration_update_effort = _get_configuration_update_effort(model, options, compat)
|
|
186
|
+
output = AssistantMessage(
|
|
187
|
+
[],
|
|
188
|
+
api=model.api,
|
|
189
|
+
provider=model.provider,
|
|
190
|
+
model=model.id,
|
|
191
|
+
usage=Usage(),
|
|
192
|
+
stop_reason="pending",
|
|
193
|
+
provider_thinking_level=configuration_update_effort,
|
|
194
|
+
)
|
|
195
|
+
signal = options.get("signal")
|
|
196
|
+
try:
|
|
197
|
+
async with abort_scope(signal):
|
|
198
|
+
api_key = _get_client_api_key(model.provider, options.get("api_key"), options.get("headers"))
|
|
199
|
+
cache_retention = _resolve_cache_retention(options.get("cache_retention"), options.get("env"))
|
|
200
|
+
cache_session_id = None if cache_retention == "none" else options.get("session_id")
|
|
201
|
+
grammar_tool_input_properties = create_grammar_tool_input_properties(
|
|
202
|
+
get_declared_tools(context.messages), compat["supportsOpenAIGrammarTools"]
|
|
203
|
+
)
|
|
204
|
+
headers = _build_request_headers(model, context, options, compat, cache_session_id)
|
|
205
|
+
client = create_openai_client(api_key=api_key, base_url=model.base_url, headers=headers, options=options)
|
|
206
|
+
params: Any = _build_params(
|
|
207
|
+
model, context, options, compat, grammar_tool_input_properties, configuration_update_effort
|
|
208
|
+
)
|
|
209
|
+
on_payload = options.get("on_payload")
|
|
210
|
+
if on_payload is not None:
|
|
211
|
+
next_params = await maybe_await(on_payload(params, model))
|
|
212
|
+
if next_params is not None:
|
|
213
|
+
params = next_params
|
|
214
|
+
create = client.responses.with_raw_response.create
|
|
215
|
+
body_arguments = sdk_body_arguments(params, headers=headers)
|
|
216
|
+
raw = await openai_request(lambda: create(stream=True, **body_arguments), options)
|
|
217
|
+
sdk_stream = open_sdk_stream(raw)
|
|
218
|
+
try:
|
|
219
|
+
on_response = options.get("on_response")
|
|
220
|
+
if on_response is not None:
|
|
221
|
+
await maybe_await(
|
|
222
|
+
on_response(ProviderResponse(raw.status_code, headers_to_record(raw.headers)), model)
|
|
223
|
+
)
|
|
224
|
+
out.push(StartEvent(output))
|
|
225
|
+
await process_responses_stream(
|
|
226
|
+
iter_sdk_stream(sdk_stream),
|
|
227
|
+
output,
|
|
228
|
+
out,
|
|
229
|
+
model,
|
|
230
|
+
{
|
|
231
|
+
"on_provider_stream_event": options.get("on_provider_stream_event"),
|
|
232
|
+
"service_tier": options.get("service_tier"),
|
|
233
|
+
"grammar_tool_input_properties": grammar_tool_input_properties,
|
|
234
|
+
"apply_service_tier_pricing": lambda usage, tier: _apply_service_tier_pricing(
|
|
235
|
+
usage, tier, model
|
|
236
|
+
),
|
|
237
|
+
},
|
|
238
|
+
)
|
|
239
|
+
finally:
|
|
240
|
+
await sdk_stream.close()
|
|
241
|
+
|
|
242
|
+
if signal is not None and signal.aborted:
|
|
243
|
+
raise RuntimeError("Request was aborted")
|
|
244
|
+
if output.stop_reason == "pending":
|
|
245
|
+
raise RuntimeError("OpenAI Responses stream ended without a stop reason")
|
|
246
|
+
if output.stop_reason in ("aborted", "error"):
|
|
247
|
+
raise RuntimeError(output.error_message or "An unknown error occurred")
|
|
248
|
+
|
|
249
|
+
out.push(DoneEvent(output.stop_reason, output)) # type: ignore[arg-type]
|
|
250
|
+
out.end()
|
|
251
|
+
except Exception as error:
|
|
252
|
+
aborted = signal is not None and signal.aborted
|
|
253
|
+
output.stop_reason = "aborted" if aborted else "error"
|
|
254
|
+
if aborted:
|
|
255
|
+
error_message = "Request was aborted"
|
|
256
|
+
else:
|
|
257
|
+
prefix = "OpenAI" if model.provider == "openai" else model.provider
|
|
258
|
+
error_message = format_provider_error(normalize_provider_error(error), f"{prefix} API error")
|
|
259
|
+
# Sign in with ChatGPT shares the subscription's usage limit with other apps.
|
|
260
|
+
output.error_message = (
|
|
261
|
+
f"{error_message}\nCheck your ChatGPT usage: {CHATGPT_USAGE_URL}"
|
|
262
|
+
if "subscription_sharing_usage_limit_exceeded" in error_message
|
|
263
|
+
else error_message
|
|
264
|
+
)
|
|
265
|
+
out.push(ErrorEvent(output.stop_reason, output)) # type: ignore[arg-type]
|
|
266
|
+
out.end()
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def stream_simple(
|
|
270
|
+
model: Model, context: TranscriptContext, options: Mapping[str, Any] | None = None
|
|
271
|
+
) -> AssistantMessageEventStream:
|
|
272
|
+
"""Stream with provider-neutral options (``reasoning``, ``tool_choice``, ...)."""
|
|
273
|
+
options = options or {}
|
|
274
|
+
_get_client_api_key(model.provider, options.get("api_key"), options.get("headers"))
|
|
275
|
+
|
|
276
|
+
base = build_base_options(model, context, options, options.get("api_key"))
|
|
277
|
+
if options.get("tool_choice") is not None:
|
|
278
|
+
base["tool_choice"] = options["tool_choice"]
|
|
279
|
+
reasoning = options.get("reasoning")
|
|
280
|
+
clamped = clamp_thinking_level(model, reasoning) if reasoning else None
|
|
281
|
+
if clamped is not None and clamped != "off":
|
|
282
|
+
base["reasoning_effort"] = clamped
|
|
283
|
+
return stream(model, context, base)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _build_request_headers(
|
|
287
|
+
model: Model,
|
|
288
|
+
context: TranscriptContext,
|
|
289
|
+
options: Mapping[str, Any],
|
|
290
|
+
compat: Mapping[str, Any],
|
|
291
|
+
session_id: str | None,
|
|
292
|
+
) -> dict[str, str | None]:
|
|
293
|
+
"""The client's ``default_headers``: ``None`` removes a header, SDK defaults included."""
|
|
294
|
+
copilot_headers: dict[str, str] | None = None
|
|
295
|
+
if model.provider == "github-copilot":
|
|
296
|
+
copilot_headers = build_copilot_dynamic_headers(
|
|
297
|
+
messages=context.messages, has_images=has_copilot_vision_input(context.messages)
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
session_headers: dict[str, str] = {}
|
|
301
|
+
if session_id:
|
|
302
|
+
affinity_format = compat["sessionAffinityFormat"]
|
|
303
|
+
if affinity_format == "openrouter":
|
|
304
|
+
session_headers["x-session-id"] = session_id
|
|
305
|
+
else:
|
|
306
|
+
if affinity_format == "openai":
|
|
307
|
+
session_headers["session_id"] = session_id
|
|
308
|
+
session_headers["x-client-request-id"] = session_id
|
|
309
|
+
|
|
310
|
+
return merge_sdk_headers(
|
|
311
|
+
{"User-Agent": get_pi_user_agent()},
|
|
312
|
+
model.headers,
|
|
313
|
+
copilot_headers,
|
|
314
|
+
session_headers,
|
|
315
|
+
# Options headers last so they can override defaults.
|
|
316
|
+
options.get("headers"),
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _build_params(
|
|
321
|
+
model: Model,
|
|
322
|
+
context: TranscriptContext,
|
|
323
|
+
options: Mapping[str, Any],
|
|
324
|
+
compat: Mapping[str, Any],
|
|
325
|
+
grammar_tool_input_properties: Mapping[str, str],
|
|
326
|
+
configuration_update_effort: str | None,
|
|
327
|
+
) -> dict[str, Any]:
|
|
328
|
+
transcript_tools = resolve_transcript_tools(
|
|
329
|
+
context.messages, compat["supportsAdditionalTools"] or compat["supportsToolSearch"]
|
|
330
|
+
)
|
|
331
|
+
tool_options = {
|
|
332
|
+
"supports_strict_mode": compat["supportsStrictMode"],
|
|
333
|
+
"supports_openai_grammar_tools": compat["supportsOpenAIGrammarTools"],
|
|
334
|
+
}
|
|
335
|
+
messages_options: dict[str, Any] = {
|
|
336
|
+
"grammar_tool_input_properties": grammar_tool_input_properties,
|
|
337
|
+
"supports_mid_convo_system_messages": compat["supportsMidConvoSystemMessages"],
|
|
338
|
+
"supports_additional_tools": compat["supportsAdditionalTools"],
|
|
339
|
+
"supports_tool_search": compat["supportsToolSearch"],
|
|
340
|
+
"tool_options": tool_options,
|
|
341
|
+
}
|
|
342
|
+
if configuration_update_effort is not None:
|
|
343
|
+
messages_options["configuration_update_effort"] = configuration_update_effort
|
|
344
|
+
messages = convert_responses_messages(model, context, OPENAI_TOOL_CALL_PROVIDERS, messages_options) # type: ignore[arg-type]
|
|
345
|
+
|
|
346
|
+
cache_retention = _resolve_cache_retention(options.get("cache_retention"), options.get("env"))
|
|
347
|
+
# Sign in with ChatGPT rejects these request fields.
|
|
348
|
+
omit_unsupported_fields = _is_chatgpt_sign_in(model, options.get("api_key"))
|
|
349
|
+
params: dict[str, Any] = {"model": model.id, "input": messages, "stream": True}
|
|
350
|
+
prompt_cache_key = None if cache_retention == "none" else clamp_openai_prompt_cache_key(options.get("session_id"))
|
|
351
|
+
if prompt_cache_key is not None:
|
|
352
|
+
params["prompt_cache_key"] = prompt_cache_key
|
|
353
|
+
if not omit_unsupported_fields:
|
|
354
|
+
retention = _get_prompt_cache_retention(compat, cache_retention)
|
|
355
|
+
if retention is not None:
|
|
356
|
+
params["prompt_cache_retention"] = retention
|
|
357
|
+
cache_options = _get_prompt_cache_options(compat, cache_retention)
|
|
358
|
+
if cache_options is not None:
|
|
359
|
+
params["prompt_cache_options"] = cache_options
|
|
360
|
+
params["store"] = False
|
|
361
|
+
|
|
362
|
+
max_tokens = options.get("max_tokens")
|
|
363
|
+
if max_tokens and compat["supportsMaxOutputTokens"] and not omit_unsupported_fields:
|
|
364
|
+
params["max_output_tokens"] = max(max_tokens, OPENAI_RESPONSES_MIN_OUTPUT_TOKENS)
|
|
365
|
+
|
|
366
|
+
if options.get("temperature") is not None and not omit_unsupported_fields:
|
|
367
|
+
params["temperature"] = options["temperature"]
|
|
368
|
+
|
|
369
|
+
if options.get("service_tier") is not None:
|
|
370
|
+
params["service_tier"] = options["service_tier"]
|
|
371
|
+
|
|
372
|
+
if transcript_tools.request_tools:
|
|
373
|
+
params["tools"] = convert_responses_tools(transcript_tools.request_tools, tool_options) # type: ignore[arg-type]
|
|
374
|
+
|
|
375
|
+
if options.get("tool_choice") is not None:
|
|
376
|
+
params["tool_choice"] = options["tool_choice"]
|
|
377
|
+
|
|
378
|
+
requested = options.get("reasoning_effort")
|
|
379
|
+
reasoning_effort = requested or ("medium" if options.get("reasoning_summary") else None)
|
|
380
|
+
if model.reasoning:
|
|
381
|
+
level_map = model.thinking_level_map or {}
|
|
382
|
+
if reasoning_effort:
|
|
383
|
+
requested_effort = _map_thinking_level(model, requested) if requested else reasoning_effort
|
|
384
|
+
effort = requested_effort if configuration_update_effort is None else CONFIGURATION_UPDATE_REQUEST_EFFORT
|
|
385
|
+
params["reasoning"] = {"effort": effort, "summary": options.get("reasoning_summary") or "auto"}
|
|
386
|
+
params["include"] = ["reasoning.encrypted_content"]
|
|
387
|
+
elif model.provider != "github-copilot" and not ("off" in level_map and level_map["off"] is None):
|
|
388
|
+
off = level_map.get("off")
|
|
389
|
+
params["reasoning"] = {"effort": off if off is not None else "none"}
|
|
390
|
+
if model.provider == "xai":
|
|
391
|
+
params["include"] = ["reasoning.encrypted_content"]
|
|
392
|
+
|
|
393
|
+
# Last so model and request sampling parameters override named request fields.
|
|
394
|
+
sampling_params = resolve_sampling_params(model, reasoning_effort or "off", options.get("sampling_params"))
|
|
395
|
+
if sampling_params:
|
|
396
|
+
params.update(sampling_params)
|
|
397
|
+
|
|
398
|
+
return params
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def _get_service_tier_cost_multiplier(model: Model, service_tier: Any) -> float:
|
|
402
|
+
if service_tier == "flex":
|
|
403
|
+
return 0.5
|
|
404
|
+
if service_tier in ("priority", "fast"):
|
|
405
|
+
return 2.5 if model.id == "gpt-5.5" else 2
|
|
406
|
+
return 1
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def _apply_service_tier_pricing(usage: Usage, service_tier: Any, model: Model) -> None:
|
|
410
|
+
multiplier = _get_service_tier_cost_multiplier(model, service_tier)
|
|
411
|
+
if multiplier == 1:
|
|
412
|
+
return
|
|
413
|
+
cost = usage.cost
|
|
414
|
+
cost.input *= multiplier
|
|
415
|
+
cost.output *= multiplier
|
|
416
|
+
cost.cache_read *= multiplier
|
|
417
|
+
cost.cache_write *= multiplier
|
|
418
|
+
cost.total = cost.input + cost.output + cost.cache_read + cost.cache_write
|