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,283 @@
|
|
|
1
|
+
"""Anthropic OAuth flow (Claude Pro/Max).
|
|
2
|
+
|
|
3
|
+
Browser login runs a loopback callback server; copy-code login is for headless use.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import base64
|
|
9
|
+
import json
|
|
10
|
+
import traceback
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from ...abort import AbortSignal
|
|
14
|
+
from ...utils.misc import get_provider_env_value
|
|
15
|
+
from ..types import AuthEvent, AuthPrompt, AuthSelectOption, Credential, LoginOptions, ModelAuth, OAuthAuth
|
|
16
|
+
from ..types import ProviderAuthInteraction as Interaction
|
|
17
|
+
from . import _common
|
|
18
|
+
from ._common import OAuthError, form_encode, json_stringify, parse_authorization_input
|
|
19
|
+
from .callback_server import (
|
|
20
|
+
CallbackResult,
|
|
21
|
+
OAuthCallbackServer,
|
|
22
|
+
start_oauth_callback_server,
|
|
23
|
+
wait_for_callback_or_manual_input,
|
|
24
|
+
)
|
|
25
|
+
from .pkce import generate_pkce
|
|
26
|
+
|
|
27
|
+
__all__ = ["AnthropicOAuth", "anthropic_oauth"]
|
|
28
|
+
|
|
29
|
+
CLIENT_ID = base64.b64decode("OWQxYzI1MGEtZTYxYi00NGQ5LTg4ZWQtNTk0NGQxOTYyZjVl").decode()
|
|
30
|
+
AUTHORIZE_URL = "https://claude.ai/oauth/authorize"
|
|
31
|
+
TOKEN_URL = "https://platform.claude.com/v1/oauth/token"
|
|
32
|
+
# Preferred so the port can be forwarded into containers or over SSH. Anthropic accepts any loopback port,
|
|
33
|
+
# so login falls back to a free port when this one cannot be bound (#10571).
|
|
34
|
+
CALLBACK_PORT = 53692
|
|
35
|
+
CALLBACK_PATH = "/callback"
|
|
36
|
+
REDIRECT_URI = f"http://localhost:{CALLBACK_PORT}{CALLBACK_PATH}"
|
|
37
|
+
COPY_CODE_REDIRECT_URI = "https://platform.claude.com/oauth/code/callback"
|
|
38
|
+
ANTHROPIC_BROWSER_LOGIN_METHOD = "browser"
|
|
39
|
+
ANTHROPIC_COPY_CODE_LOGIN_METHOD = "copy_code"
|
|
40
|
+
SCOPES = "org:create_api_key user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload"
|
|
41
|
+
_EXPIRY_SKEW_MS = 5 * 60 * 1000
|
|
42
|
+
_REQUEST_TIMEOUT_MS = 30_000
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _callback_host() -> str:
|
|
46
|
+
return get_provider_env_value("PI_OAUTH_CALLBACK_HOST") or "127.0.0.1"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _format_error_details(error: BaseException, depth: int = 0) -> str:
|
|
50
|
+
details = [f"{type(error).__name__}: {error}"]
|
|
51
|
+
code = getattr(error, "code", None)
|
|
52
|
+
if code:
|
|
53
|
+
details.append(f"code={code}")
|
|
54
|
+
errno = getattr(error, "errno", None)
|
|
55
|
+
if errno is not None:
|
|
56
|
+
details.append(f"errno={errno}")
|
|
57
|
+
cause = error.__cause__ if error.__cause__ is not None or error.__suppress_context__ else error.__context__
|
|
58
|
+
if cause is not None and depth < 8:
|
|
59
|
+
details.append(f"cause={_format_error_details(cause, depth + 1)}")
|
|
60
|
+
if error.__traceback__ is not None:
|
|
61
|
+
details.append(f"stack={''.join(traceback.format_tb(error.__traceback__)).rstrip()}")
|
|
62
|
+
return "; ".join(details)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
async def _post_json(url: str, body: dict[str, Any], signal: AbortSignal) -> str:
|
|
66
|
+
response = await _common.fetch(
|
|
67
|
+
url,
|
|
68
|
+
method="POST",
|
|
69
|
+
headers={"Content-Type": "application/json", "Accept": "application/json"},
|
|
70
|
+
body=json_stringify(body),
|
|
71
|
+
signal=signal,
|
|
72
|
+
timeout_ms=_REQUEST_TIMEOUT_MS,
|
|
73
|
+
)
|
|
74
|
+
response_body = response.text
|
|
75
|
+
if not response.is_success:
|
|
76
|
+
raise OAuthError(f"HTTP request failed. status={response.status_code}; url={url}; body={response_body}")
|
|
77
|
+
return response_body
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _credential_from_token(data: Any) -> Credential:
|
|
81
|
+
token = data if isinstance(data, dict) else {}
|
|
82
|
+
credential: Credential = {"type": "oauth"}
|
|
83
|
+
if token.get("refresh_token") is not None:
|
|
84
|
+
credential["refresh"] = token["refresh_token"]
|
|
85
|
+
if token.get("access_token") is not None:
|
|
86
|
+
credential["access"] = token["access_token"]
|
|
87
|
+
credential["expires"] = _common.now_ms() + _common.expires_in_ms(token.get("expires_in")) - _EXPIRY_SKEW_MS
|
|
88
|
+
return credential
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
async def _exchange_authorization_code(
|
|
92
|
+
code: str, state: str, verifier: str, redirect_uri: str, signal: AbortSignal
|
|
93
|
+
) -> Credential:
|
|
94
|
+
try:
|
|
95
|
+
response_body = await _post_json(
|
|
96
|
+
TOKEN_URL,
|
|
97
|
+
{
|
|
98
|
+
"grant_type": "authorization_code",
|
|
99
|
+
"client_id": CLIENT_ID,
|
|
100
|
+
"code": code,
|
|
101
|
+
"state": state,
|
|
102
|
+
"redirect_uri": redirect_uri,
|
|
103
|
+
"code_verifier": verifier,
|
|
104
|
+
},
|
|
105
|
+
signal,
|
|
106
|
+
)
|
|
107
|
+
except Exception as error:
|
|
108
|
+
raise OAuthError(
|
|
109
|
+
f"Token exchange request failed. url={TOKEN_URL}; redirect_uri={redirect_uri}; "
|
|
110
|
+
f"response_type=authorization_code; details={_format_error_details(error)}"
|
|
111
|
+
) from error
|
|
112
|
+
|
|
113
|
+
try:
|
|
114
|
+
token_data = json.loads(response_body)
|
|
115
|
+
except ValueError as error:
|
|
116
|
+
raise OAuthError(
|
|
117
|
+
f"Token exchange returned invalid JSON. url={TOKEN_URL}; body={response_body}; "
|
|
118
|
+
f"details={_format_error_details(error)}"
|
|
119
|
+
) from error
|
|
120
|
+
|
|
121
|
+
return _credential_from_token(token_data)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _authorize_url(redirect_uri: str, challenge: str, verifier: str) -> str:
|
|
125
|
+
params = {
|
|
126
|
+
"code": "true",
|
|
127
|
+
"client_id": CLIENT_ID,
|
|
128
|
+
"response_type": "code",
|
|
129
|
+
"redirect_uri": redirect_uri,
|
|
130
|
+
"scope": SCOPES,
|
|
131
|
+
"code_challenge": challenge,
|
|
132
|
+
"code_challenge_method": "S256",
|
|
133
|
+
"state": verifier,
|
|
134
|
+
}
|
|
135
|
+
return f"{AUTHORIZE_URL}?{form_encode(params)}"
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
async def _login_anthropic(interaction: Interaction) -> Credential:
|
|
139
|
+
verifier, challenge = generate_pkce()
|
|
140
|
+
|
|
141
|
+
async def identity(code: str) -> str:
|
|
142
|
+
return code
|
|
143
|
+
|
|
144
|
+
async def start_callback_server(port: int) -> OAuthCallbackServer[str]:
|
|
145
|
+
return await start_oauth_callback_server(
|
|
146
|
+
provider_name="Anthropic",
|
|
147
|
+
host=_callback_host(),
|
|
148
|
+
port=port,
|
|
149
|
+
path=CALLBACK_PATH,
|
|
150
|
+
redirect_host="localhost",
|
|
151
|
+
state=verifier,
|
|
152
|
+
complete=identity,
|
|
153
|
+
signal=interaction.signal,
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
# Without a callback server, login continues with the pasted redirect URL.
|
|
157
|
+
callback: OAuthCallbackServer[str] | None = None
|
|
158
|
+
for port in (CALLBACK_PORT, 0):
|
|
159
|
+
try:
|
|
160
|
+
callback = await start_callback_server(port)
|
|
161
|
+
break
|
|
162
|
+
except Exception:
|
|
163
|
+
continue
|
|
164
|
+
redirect_uri = callback.redirect_uri if callback is not None else REDIRECT_URI
|
|
165
|
+
|
|
166
|
+
try:
|
|
167
|
+
interaction.notify(
|
|
168
|
+
AuthEvent(
|
|
169
|
+
type="auth_url",
|
|
170
|
+
url=_authorize_url(redirect_uri, challenge, verifier),
|
|
171
|
+
instructions=(
|
|
172
|
+
"Complete login in your browser. If the browser is on another machine, "
|
|
173
|
+
"paste the final redirect URL here."
|
|
174
|
+
),
|
|
175
|
+
)
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
result = await wait_for_callback_or_manual_input(
|
|
179
|
+
interaction,
|
|
180
|
+
callback,
|
|
181
|
+
message="Complete login in your browser, or paste the authorization code / redirect URL here:",
|
|
182
|
+
placeholder=redirect_uri,
|
|
183
|
+
)
|
|
184
|
+
code: str | None
|
|
185
|
+
state = verifier
|
|
186
|
+
if isinstance(result, CallbackResult):
|
|
187
|
+
code = result.value
|
|
188
|
+
else:
|
|
189
|
+
code, parsed_state = parse_authorization_input(result.input)
|
|
190
|
+
if parsed_state and parsed_state != verifier:
|
|
191
|
+
raise OAuthError("OAuth state mismatch")
|
|
192
|
+
state = parsed_state if parsed_state is not None else verifier
|
|
193
|
+
|
|
194
|
+
if not code:
|
|
195
|
+
raise OAuthError("Missing authorization code")
|
|
196
|
+
interaction.notify(AuthEvent(type="progress", message="Exchanging authorization code for tokens..."))
|
|
197
|
+
return await _exchange_authorization_code(code, state, verifier, redirect_uri, interaction.signal)
|
|
198
|
+
finally:
|
|
199
|
+
if callback is not None:
|
|
200
|
+
callback.close()
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
async def _login_anthropic_copy_code(interaction: Interaction) -> Credential:
|
|
204
|
+
verifier, challenge = generate_pkce()
|
|
205
|
+
interaction.notify(
|
|
206
|
+
AuthEvent(
|
|
207
|
+
type="auth_url",
|
|
208
|
+
url=_authorize_url(COPY_CODE_REDIRECT_URI, challenge, verifier),
|
|
209
|
+
instructions="Complete login in your browser, then copy the code Anthropic shows and paste it here.",
|
|
210
|
+
)
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
entered = await interaction.prompt(
|
|
214
|
+
AuthPrompt(
|
|
215
|
+
type="manual_code",
|
|
216
|
+
message="Paste the code Anthropic shows after you sign in:",
|
|
217
|
+
placeholder="code#state",
|
|
218
|
+
signal=interaction.signal,
|
|
219
|
+
)
|
|
220
|
+
)
|
|
221
|
+
code, state = parse_authorization_input(entered)
|
|
222
|
+
if state and state != verifier:
|
|
223
|
+
raise OAuthError("OAuth state mismatch")
|
|
224
|
+
if not code:
|
|
225
|
+
raise OAuthError("Missing authorization code")
|
|
226
|
+
interaction.notify(AuthEvent(type="progress", message="Exchanging authorization code for tokens..."))
|
|
227
|
+
return await _exchange_authorization_code(
|
|
228
|
+
code, state if state is not None else verifier, verifier, COPY_CODE_REDIRECT_URI, interaction.signal
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
async def _refresh_anthropic_token(refresh_token: str, signal: AbortSignal) -> Credential:
|
|
233
|
+
try:
|
|
234
|
+
response_body = await _post_json(
|
|
235
|
+
TOKEN_URL,
|
|
236
|
+
{"grant_type": "refresh_token", "client_id": CLIENT_ID, "refresh_token": refresh_token},
|
|
237
|
+
signal,
|
|
238
|
+
)
|
|
239
|
+
except Exception as error:
|
|
240
|
+
raise OAuthError(
|
|
241
|
+
f"Anthropic token refresh request failed. url={TOKEN_URL}; details={_format_error_details(error)}"
|
|
242
|
+
) from error
|
|
243
|
+
|
|
244
|
+
try:
|
|
245
|
+
data = json.loads(response_body)
|
|
246
|
+
except ValueError as error:
|
|
247
|
+
raise OAuthError(
|
|
248
|
+
f"Anthropic token refresh returned invalid JSON. url={TOKEN_URL}; body={response_body}; "
|
|
249
|
+
f"details={_format_error_details(error)}"
|
|
250
|
+
) from error
|
|
251
|
+
|
|
252
|
+
return _credential_from_token(data)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
class AnthropicOAuth(OAuthAuth):
|
|
256
|
+
name = "Anthropic (Claude Pro/Max)"
|
|
257
|
+
is_subscription = True
|
|
258
|
+
|
|
259
|
+
async def login(self, interaction: Interaction, options: LoginOptions | None = None) -> Credential:
|
|
260
|
+
method = await interaction.prompt(
|
|
261
|
+
AuthPrompt(
|
|
262
|
+
type="select",
|
|
263
|
+
message="Select Anthropic login method:",
|
|
264
|
+
options=[
|
|
265
|
+
AuthSelectOption(ANTHROPIC_BROWSER_LOGIN_METHOD, "Browser login (default)"),
|
|
266
|
+
AuthSelectOption(ANTHROPIC_COPY_CODE_LOGIN_METHOD, "Copy code login (headless)"),
|
|
267
|
+
],
|
|
268
|
+
)
|
|
269
|
+
)
|
|
270
|
+
if method == ANTHROPIC_COPY_CODE_LOGIN_METHOD:
|
|
271
|
+
return await _login_anthropic_copy_code(interaction)
|
|
272
|
+
if method != ANTHROPIC_BROWSER_LOGIN_METHOD:
|
|
273
|
+
raise OAuthError(f"Unknown Anthropic login method: {method}")
|
|
274
|
+
return await _login_anthropic(interaction)
|
|
275
|
+
|
|
276
|
+
async def refresh(self, credential: Credential, signal: AbortSignal) -> Credential:
|
|
277
|
+
return await _refresh_anthropic_token(credential["refresh"], signal)
|
|
278
|
+
|
|
279
|
+
async def to_auth(self, credential: Credential) -> ModelAuth:
|
|
280
|
+
return ModelAuth(api_key=credential["access"])
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
anthropic_oauth = AnthropicOAuth()
|
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
"""Loopback OAuth redirect handler shared by the browser sign-in flows.
|
|
2
|
+
|
|
3
|
+
The server is a minimal asyncio HTTP/1.1 listener (one request per connection)
|
|
4
|
+
running on the caller's event loop, so it is cancellable together with the login.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import contextlib
|
|
11
|
+
import os
|
|
12
|
+
import socket
|
|
13
|
+
from collections.abc import Awaitable, Callable, Mapping
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from http import HTTPStatus
|
|
16
|
+
from typing import Any, ClassVar, Generic, TypeVar
|
|
17
|
+
|
|
18
|
+
from ...abort import AbortController, AbortSignal
|
|
19
|
+
from ...utils.oauth_page import oauth_error_html, oauth_success_html
|
|
20
|
+
from ..types import AuthPrompt, ProviderAuthInteraction
|
|
21
|
+
from ._common import OAuthError, ParsedUrl, login_cancelled, parse_url
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"LoopbackHttpServer",
|
|
25
|
+
"OAuthCallbackServer",
|
|
26
|
+
"CallbackResult",
|
|
27
|
+
"ManualInput",
|
|
28
|
+
"start_oauth_callback_server",
|
|
29
|
+
"wait_for_callback_or_manual_input",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
T = TypeVar("T")
|
|
33
|
+
|
|
34
|
+
#: ``(method, url) -> (status, html)`` where ``url`` is the request target resolved against the server origin.
|
|
35
|
+
RequestHandler = Callable[[str, ParsedUrl | None], Awaitable[tuple[int, str]]]
|
|
36
|
+
|
|
37
|
+
_MAX_HEAD_BYTES = 64 * 1024
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class _Connection:
|
|
41
|
+
__slots__ = ("active", "writer")
|
|
42
|
+
|
|
43
|
+
def __init__(self, writer: asyncio.StreamWriter) -> None:
|
|
44
|
+
self.writer = writer
|
|
45
|
+
self.active = False
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class LoopbackHttpServer:
|
|
49
|
+
"""Tiny HTTP/1.1 server that answers every request with an HTML page and closes the connection."""
|
|
50
|
+
|
|
51
|
+
def __init__(self, handler: RequestHandler, *, headers: Mapping[str, str] | None = None) -> None:
|
|
52
|
+
self._handler = handler
|
|
53
|
+
self._headers = dict(headers or {"content-type": "text/html; charset=utf-8"})
|
|
54
|
+
self._server: asyncio.Server | None = None
|
|
55
|
+
self._connections: set[_Connection] = set()
|
|
56
|
+
self.port = 0
|
|
57
|
+
self._origin = "http://localhost"
|
|
58
|
+
|
|
59
|
+
async def listen(self, host: str, port: int) -> None:
|
|
60
|
+
"""Bind and start serving. Raises ``OSError`` (e.g. ``EADDRINUSE``) when the port is taken."""
|
|
61
|
+
loop = asyncio.get_running_loop()
|
|
62
|
+
# Bind the first resolved address only (like Node's `server.listen(port, host)`), so a
|
|
63
|
+
# free port picked with `port=0` is the same for every client of this server.
|
|
64
|
+
infos = await loop.getaddrinfo(host, port, type=socket.SOCK_STREAM, flags=socket.AI_PASSIVE)
|
|
65
|
+
family, sock_type, proto, _, address = infos[0]
|
|
66
|
+
sock = socket.socket(family, sock_type, proto)
|
|
67
|
+
try:
|
|
68
|
+
if os.name != "nt":
|
|
69
|
+
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
70
|
+
sock.bind(address)
|
|
71
|
+
sock.listen(511)
|
|
72
|
+
sock.setblocking(False)
|
|
73
|
+
self._server = await asyncio.start_server(self._serve, sock=sock, limit=_MAX_HEAD_BYTES)
|
|
74
|
+
except BaseException:
|
|
75
|
+
sock.close()
|
|
76
|
+
raise
|
|
77
|
+
self.port = sock.getsockname()[1]
|
|
78
|
+
self._origin = f"http://{f'[{host}]' if ':' in host else host}:{self.port}"
|
|
79
|
+
|
|
80
|
+
def close(self) -> None:
|
|
81
|
+
"""Stop accepting connections and drop idle ones; requests being handled complete."""
|
|
82
|
+
if self._server is not None:
|
|
83
|
+
self._server.close()
|
|
84
|
+
for connection in list(self._connections):
|
|
85
|
+
if not connection.active:
|
|
86
|
+
self._drop(connection)
|
|
87
|
+
|
|
88
|
+
def close_all_connections(self) -> None:
|
|
89
|
+
for connection in list(self._connections):
|
|
90
|
+
self._drop(connection)
|
|
91
|
+
|
|
92
|
+
def _drop(self, connection: _Connection) -> None:
|
|
93
|
+
self._connections.discard(connection)
|
|
94
|
+
with contextlib.suppress(Exception):
|
|
95
|
+
connection.writer.close()
|
|
96
|
+
|
|
97
|
+
async def _serve(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
|
|
98
|
+
connection = _Connection(writer)
|
|
99
|
+
self._connections.add(connection)
|
|
100
|
+
try:
|
|
101
|
+
try:
|
|
102
|
+
head = await reader.readuntil(b"\r\n\r\n")
|
|
103
|
+
except (asyncio.IncompleteReadError, asyncio.LimitOverrunError, ConnectionError):
|
|
104
|
+
return
|
|
105
|
+
connection.active = True
|
|
106
|
+
request_line = head.split(b"\r\n", 1)[0].decode("latin-1")
|
|
107
|
+
parts = request_line.split(" ")
|
|
108
|
+
if len(parts) != 3 or not parts[2].startswith("HTTP/"):
|
|
109
|
+
status, html = 400, oauth_error_html("Bad request.")
|
|
110
|
+
else:
|
|
111
|
+
method, target = parts[0], parts[1]
|
|
112
|
+
try:
|
|
113
|
+
status, html = await self._handler(method, parse_url(target, self._origin))
|
|
114
|
+
except Exception:
|
|
115
|
+
status, html = 500, oauth_error_html("Internal error while processing the callback.")
|
|
116
|
+
body = html.encode("utf-8")
|
|
117
|
+
try:
|
|
118
|
+
reason = HTTPStatus(status).phrase
|
|
119
|
+
except ValueError:
|
|
120
|
+
reason = ""
|
|
121
|
+
lines = [f"HTTP/1.1 {status} {reason}"]
|
|
122
|
+
lines.extend(f"{name}: {value}" for name, value in self._headers.items())
|
|
123
|
+
lines.append(f"content-length: {len(body)}")
|
|
124
|
+
lines.append("connection: close")
|
|
125
|
+
writer.write(("\r\n".join(lines) + "\r\n\r\n").encode("latin-1") + body)
|
|
126
|
+
await writer.drain()
|
|
127
|
+
except (ConnectionError, RuntimeError):
|
|
128
|
+
pass
|
|
129
|
+
finally:
|
|
130
|
+
self._connections.discard(connection)
|
|
131
|
+
with contextlib.suppress(Exception):
|
|
132
|
+
writer.close()
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class OAuthCallbackServer(Generic[T]):
|
|
136
|
+
"""A running loopback redirect handler. Create with :func:`start_oauth_callback_server`."""
|
|
137
|
+
|
|
138
|
+
def __init__(
|
|
139
|
+
self,
|
|
140
|
+
*,
|
|
141
|
+
provider_name: str,
|
|
142
|
+
path: str,
|
|
143
|
+
state: str | None,
|
|
144
|
+
complete: Callable[[str], Awaitable[T]],
|
|
145
|
+
) -> None:
|
|
146
|
+
self.redirect_uri = ""
|
|
147
|
+
self._provider_name = provider_name
|
|
148
|
+
self._path = path
|
|
149
|
+
self._state = state
|
|
150
|
+
self._complete = complete
|
|
151
|
+
self._loop = asyncio.get_running_loop()
|
|
152
|
+
self._future: asyncio.Future[T | None] = self._loop.create_future()
|
|
153
|
+
# A cancelled or closed wait may never be observed.
|
|
154
|
+
self._future.add_done_callback(_consume_future)
|
|
155
|
+
self._claimed = False
|
|
156
|
+
self._settled = False
|
|
157
|
+
self._timer: asyncio.TimerHandle | None = None
|
|
158
|
+
self._remove_abort: Callable[[], None] | None = None
|
|
159
|
+
self._http = LoopbackHttpServer(
|
|
160
|
+
self._handle, headers={"content-type": "text/html; charset=utf-8", "cache-control": "no-store"}
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
def _finish(self, *, value: T | None = None, error: BaseException | None = None) -> None:
|
|
164
|
+
if self._settled:
|
|
165
|
+
return
|
|
166
|
+
self._settled = True
|
|
167
|
+
if self._timer is not None:
|
|
168
|
+
self._timer.cancel()
|
|
169
|
+
if self._remove_abort is not None:
|
|
170
|
+
self._remove_abort()
|
|
171
|
+
if self._future.done():
|
|
172
|
+
return
|
|
173
|
+
if error is not None:
|
|
174
|
+
self._future.set_exception(error)
|
|
175
|
+
else:
|
|
176
|
+
self._future.set_result(value)
|
|
177
|
+
|
|
178
|
+
def _on_abort(self) -> None:
|
|
179
|
+
def finish() -> None:
|
|
180
|
+
self._finish(error=login_cancelled())
|
|
181
|
+
|
|
182
|
+
try:
|
|
183
|
+
running = asyncio.get_running_loop()
|
|
184
|
+
except RuntimeError:
|
|
185
|
+
running = None
|
|
186
|
+
if running is self._loop:
|
|
187
|
+
finish()
|
|
188
|
+
elif not self._loop.is_closed():
|
|
189
|
+
self._loop.call_soon_threadsafe(finish)
|
|
190
|
+
|
|
191
|
+
async def _handle(self, method: str, url: ParsedUrl | None) -> tuple[int, str]:
|
|
192
|
+
if method != "GET" or url is None or url.pathname != self._path:
|
|
193
|
+
return 404, oauth_error_html("Callback route not found.")
|
|
194
|
+
if self._state is not None and url.param("state") != self._state:
|
|
195
|
+
return 400, oauth_error_html("State mismatch.")
|
|
196
|
+
if self._claimed or self._settled:
|
|
197
|
+
return 409, oauth_error_html("This sign-in has already been handled.")
|
|
198
|
+
error = url.param("error")
|
|
199
|
+
if error:
|
|
200
|
+
description = url.param("error_description")
|
|
201
|
+
if description is None:
|
|
202
|
+
description = error
|
|
203
|
+
self._finish(error=OAuthError(f"{self._provider_name} authorization failed: {description}"))
|
|
204
|
+
return 400, oauth_error_html(f"{self._provider_name} authorization failed.", description)
|
|
205
|
+
code = url.param("code")
|
|
206
|
+
if not code:
|
|
207
|
+
return 400, oauth_error_html("Missing authorization code.")
|
|
208
|
+
self._claimed = True
|
|
209
|
+
try:
|
|
210
|
+
value = await self._complete(code)
|
|
211
|
+
except Exception as failure:
|
|
212
|
+
self._finish(error=failure)
|
|
213
|
+
return 502, oauth_error_html(f"{self._provider_name} sign-in failed.", str(failure))
|
|
214
|
+
self._finish(value=value)
|
|
215
|
+
return 200, oauth_success_html(f"Signed in to {self._provider_name}. You may now close this page.")
|
|
216
|
+
|
|
217
|
+
async def wait(self) -> T | None:
|
|
218
|
+
"""The result of ``complete``, or ``None`` after :meth:`cancel`.
|
|
219
|
+
|
|
220
|
+
Raises when the provider redirects with an error, ``complete`` fails, the signal
|
|
221
|
+
aborts, or the timeout elapses.
|
|
222
|
+
"""
|
|
223
|
+
return await asyncio.shield(self._future)
|
|
224
|
+
|
|
225
|
+
def cancel(self) -> None:
|
|
226
|
+
"""Stop waiting for the browser unless a callback is already being completed."""
|
|
227
|
+
if not self._claimed:
|
|
228
|
+
self._finish(value=None)
|
|
229
|
+
|
|
230
|
+
def close(self) -> None:
|
|
231
|
+
self._finish(error=OAuthError("OAuth callback server closed"))
|
|
232
|
+
self._http.close()
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
async def start_oauth_callback_server(
|
|
236
|
+
*,
|
|
237
|
+
provider_name: str,
|
|
238
|
+
host: str,
|
|
239
|
+
port: int,
|
|
240
|
+
path: str,
|
|
241
|
+
complete: Callable[[str], Awaitable[T]],
|
|
242
|
+
redirect_host: str | None = None,
|
|
243
|
+
state: str | None = None,
|
|
244
|
+
signal: AbortSignal | None = None,
|
|
245
|
+
timeout_ms: float | None = None,
|
|
246
|
+
) -> OAuthCallbackServer[T]:
|
|
247
|
+
"""Listen for the OAuth redirect on ``host:port`` (``0`` picks a free port).
|
|
248
|
+
|
|
249
|
+
``provider_name`` is shown on the browser page. ``redirect_host`` replaces ``host``
|
|
250
|
+
in ``redirect_uri`` (for example ``localhost``). ``state`` is the expected ``state``
|
|
251
|
+
parameter (omit when the provider sends none). ``complete`` finishes the sign-in with
|
|
252
|
+
the received code before the browser page is sent, so the page can show exchange
|
|
253
|
+
failures; pass ``lambda code: code`` (async) to exchange the code later.
|
|
254
|
+
"""
|
|
255
|
+
if signal is not None and signal.aborted:
|
|
256
|
+
raise login_cancelled()
|
|
257
|
+
server: OAuthCallbackServer[T] = OAuthCallbackServer(
|
|
258
|
+
provider_name=provider_name, path=path, state=state, complete=complete
|
|
259
|
+
)
|
|
260
|
+
await server._http.listen(host, port)
|
|
261
|
+
if signal is not None:
|
|
262
|
+
server._remove_abort = signal.add_listener(server._on_abort)
|
|
263
|
+
if timeout_ms is not None:
|
|
264
|
+
server._timer = server._loop.call_later(
|
|
265
|
+
timeout_ms / 1000, lambda: server._finish(error=OAuthError(f"{provider_name} sign-in timed out"))
|
|
266
|
+
)
|
|
267
|
+
effective_host = redirect_host if redirect_host is not None else host
|
|
268
|
+
formatted = f"[{effective_host}]" if ":" in effective_host else effective_host
|
|
269
|
+
server.redirect_uri = f"http://{formatted}:{server._http.port}{path}"
|
|
270
|
+
return server
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
@dataclass(frozen=True, slots=True)
|
|
274
|
+
class CallbackResult:
|
|
275
|
+
"""The browser callback completed with ``value``."""
|
|
276
|
+
|
|
277
|
+
value: Any
|
|
278
|
+
type: ClassVar[str] = "callback"
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
@dataclass(frozen=True, slots=True)
|
|
282
|
+
class ManualInput:
|
|
283
|
+
"""The user pasted ``input`` (a code or redirect URL)."""
|
|
284
|
+
|
|
285
|
+
input: str
|
|
286
|
+
type: ClassVar[str] = "manual"
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
async def wait_for_callback_or_manual_input(
|
|
290
|
+
interaction: ProviderAuthInteraction,
|
|
291
|
+
callback: OAuthCallbackServer[Any] | None,
|
|
292
|
+
*,
|
|
293
|
+
message: str,
|
|
294
|
+
placeholder: str,
|
|
295
|
+
) -> CallbackResult | ManualInput:
|
|
296
|
+
"""Wait for the browser callback, or for the user to paste the code or redirect URL.
|
|
297
|
+
|
|
298
|
+
The paste prompt covers browsers that cannot reach the loopback server (for example
|
|
299
|
+
over SSH). Without a callback server only the manual prompt is used. Once one side
|
|
300
|
+
wins, the manual prompt's signal aborts so UIs can dismiss it.
|
|
301
|
+
"""
|
|
302
|
+
manual_abort = AbortController()
|
|
303
|
+
manual_errors: list[BaseException] = []
|
|
304
|
+
|
|
305
|
+
async def manual() -> str | None:
|
|
306
|
+
try:
|
|
307
|
+
value = await interaction.prompt(
|
|
308
|
+
AuthPrompt(type="manual_code", message=message, placeholder=placeholder, signal=manual_abort.signal)
|
|
309
|
+
)
|
|
310
|
+
except Exception as error:
|
|
311
|
+
manual_errors.append(error)
|
|
312
|
+
if callback is not None:
|
|
313
|
+
callback.cancel()
|
|
314
|
+
return None
|
|
315
|
+
if callback is not None:
|
|
316
|
+
callback.cancel()
|
|
317
|
+
return value
|
|
318
|
+
|
|
319
|
+
manual_task = asyncio.ensure_future(manual())
|
|
320
|
+
try:
|
|
321
|
+
value = await callback.wait() if callback is not None else None
|
|
322
|
+
if manual_errors:
|
|
323
|
+
raise manual_errors[0]
|
|
324
|
+
if value is not None:
|
|
325
|
+
return CallbackResult(value)
|
|
326
|
+
entered = await manual_task
|
|
327
|
+
if manual_errors:
|
|
328
|
+
raise manual_errors[0]
|
|
329
|
+
return ManualInput(entered or "")
|
|
330
|
+
finally:
|
|
331
|
+
manual_abort.abort()
|
|
332
|
+
if not manual_task.done():
|
|
333
|
+
# Prompts that ignore their signal would otherwise stay pending forever.
|
|
334
|
+
manual_task.cancel()
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _consume_future(future: asyncio.Future[Any]) -> None:
|
|
338
|
+
if not future.cancelled():
|
|
339
|
+
future.exception()
|