-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathclient.py
More file actions
154 lines (127 loc) · 5.41 KB
/
Copy pathclient.py
File metadata and controls
154 lines (127 loc) · 5.41 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
# 修改历史 (Revision History)
# ==================================
# 版本: v1.1
# 日期: 2026-06-24
# 修改说明: 在调用 load_auth 时显式指定 auto_login=False,防止后台服务在 Token 过期且静默刷新失败时强拉浏览器导致崩溃。
"""High-level Copilot client — the recommended entry point.
One client, many conversations addressed by id. :meth:`CopilotClient.chat`
returns the full reply plus the conversation id; pass that id back to continue
the same conversation, or omit it to start a fresh one. :meth:`CopilotClient.stream`
is the incremental variant.
from copilot import CopilotClient
client = CopilotClient() # loads signed-in auth once
r = client.chat("My name is Tomato. Remember it.")
print(r.text, r.conversation_id)
r2 = client.chat("What's my name?", r.conversation_id) # continue
print(r2.text)
for chunk in client.stream("Tell me a joke"): # new conversation, streamed
print(chunk, end="", flush=True)
The signed-in access token is refreshed transparently; sign in once with
``python -m copilot login``. Pass ``anonymous=True`` to skip sign-in (only where
anonymous consumer chat is available), or ``proxy=...`` to route through a
supported region.
"""
import time
from dataclasses import dataclass, field
from typing import Generator, List, Optional, Union
from .auth import AUTH_MAX_AGE, load_auth
from .driver import Copilot
from .models import Conversation, ImageResponse
@dataclass
class ChatReply:
"""The full result of a :meth:`CopilotClient.chat` call."""
text: str
conversation_id: Optional[str]
images: List[ImageResponse] = field(default_factory=list)
class ChatStream:
"""Iterable stream of reply chunks that also exposes the conversation id.
Yields ``str`` text chunks (and :class:`~copilot.models.ImageResponse` for
generated images). ``conversation_id`` is known up front when continuing an
existing conversation, and is populated as soon as iteration begins when a
new conversation is created.
"""
def __init__(self, chunks: Generator, conversation_id: Optional[str]):
self._chunks = chunks
self.conversation_id = conversation_id
def __iter__(self) -> Generator[Union[str, ImageResponse], None, None]:
for item in self._chunks:
if isinstance(item, Conversation):
self.conversation_id = item.conversation_id
else:
yield item
class CopilotClient:
"""A Copilot client: one object, many conversations addressed by id.
Parameters
----------
anonymous:
Skip sign-in and talk to Copilot anonymously. Only works where the
anonymous consumer experience is available (it is geo-blocked in some
regions, e.g. India). Default ``False`` uses the signed-in session.
proxy:
Optional ``scheme://user:pass@host:port`` proxy, applied to both the
auth refresh and every request.
max_age:
Seconds a cached access token is trusted before it is refreshed.
"""
def __init__(
self,
anonymous: bool = False,
proxy: Optional[str] = None,
max_age: int = AUTH_MAX_AGE,
):
self._driver = Copilot()
self._anonymous = anonymous
self._proxy = proxy
self._max_age = max_age
self._auth: Optional[dict] = None
def stream(
self,
prompt: str,
conversation_id: Optional[str] = None,
**kwargs,
) -> ChatStream:
"""Stream the reply to ``prompt`` as a :class:`ChatStream`.
Starts a new conversation when ``conversation_id`` is ``None``; otherwise
continues that conversation. Read ``.conversation_id`` on the returned
stream (during/after iteration) to continue the chat later.
"""
auth = self._fresh_auth()
kw = dict(
stream=True,
proxy=self._proxy,
cookies=auth["cookies"] if auth else None,
access_token=auth["access_token"] if auth else None,
**kwargs,
)
if conversation_id is None:
# New conversation: have the driver hand back its id.
kw["return_conversation"] = True
else:
kw["conversation_id"] = conversation_id
chunks = self._driver.create_completion(prompt, **kw)
return ChatStream(chunks, conversation_id)
def chat(
self,
prompt: str,
conversation_id: Optional[str] = None,
**kwargs,
) -> ChatReply:
"""Return the full reply to ``prompt`` as a :class:`ChatReply`.
Buffers the whole response; use :meth:`stream` for incremental output.
"""
s = self.stream(prompt, conversation_id=conversation_id, **kwargs)
text: List[str] = []
images: List[ImageResponse] = []
for item in s:
if isinstance(item, str):
text.append(item)
elif isinstance(item, ImageResponse):
images.append(item)
return ChatReply("".join(text), s.conversation_id, images)
def _fresh_auth(self) -> Optional[dict]:
"""Return current signed-in auth, refreshing it when stale (or None)."""
if self._anonymous:
return None
if self._auth is None or (time.time() - self._auth.get("saved_at", 0)) >= self._max_age:
self._auth = load_auth(max_age=self._max_age, proxy=self._proxy, auto_login=False)
return self._auth