python-fastllm 0.0.47__tar.gz → 0.0.48__tar.gz

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.
Files changed (23) hide show
  1. {python_fastllm-0.0.47 → python_fastllm-0.0.48}/PKG-INFO +1 -1
  2. {python_fastllm-0.0.47 → python_fastllm-0.0.48}/fastllm/__init__.py +1 -1
  3. {python_fastllm-0.0.47 → python_fastllm-0.0.48}/fastllm/_modidx.py +16 -1
  4. {python_fastllm-0.0.47 → python_fastllm-0.0.48}/fastllm/acomplete.py +5 -2
  5. python_fastllm-0.0.48/fastllm/codex.py +118 -0
  6. {python_fastllm-0.0.47 → python_fastllm-0.0.48}/fastllm/responses.py +7 -6
  7. {python_fastllm-0.0.47 → python_fastllm-0.0.48}/pyproject.toml +3 -0
  8. {python_fastllm-0.0.47 → python_fastllm-0.0.48}/python_fastllm.egg-info/PKG-INFO +1 -1
  9. {python_fastllm-0.0.47 → python_fastllm-0.0.48}/python_fastllm.egg-info/entry_points.txt +3 -0
  10. python_fastllm-0.0.47/fastllm/codex.py +0 -7
  11. {python_fastllm-0.0.47 → python_fastllm-0.0.48}/README.md +0 -0
  12. {python_fastllm-0.0.47 → python_fastllm-0.0.48}/fastllm/anthropic.py +0 -0
  13. {python_fastllm-0.0.47 → python_fastllm-0.0.48}/fastllm/chat.py +0 -0
  14. {python_fastllm-0.0.47 → python_fastllm-0.0.48}/fastllm/gemini.py +0 -0
  15. {python_fastllm-0.0.47 → python_fastllm-0.0.48}/fastllm/openai_chat.py +0 -0
  16. {python_fastllm-0.0.47 → python_fastllm-0.0.48}/fastllm/openai_responses.py +0 -0
  17. {python_fastllm-0.0.47 → python_fastllm-0.0.48}/fastllm/streaming.py +0 -0
  18. {python_fastllm-0.0.47 → python_fastllm-0.0.48}/fastllm/types.py +0 -0
  19. {python_fastllm-0.0.47 → python_fastllm-0.0.48}/python_fastllm.egg-info/SOURCES.txt +0 -0
  20. {python_fastllm-0.0.47 → python_fastllm-0.0.48}/python_fastllm.egg-info/dependency_links.txt +0 -0
  21. {python_fastllm-0.0.47 → python_fastllm-0.0.48}/python_fastllm.egg-info/requires.txt +0 -0
  22. {python_fastllm-0.0.47 → python_fastllm-0.0.48}/python_fastllm.egg-info/top_level.txt +0 -0
  23. {python_fastllm-0.0.47 → python_fastllm-0.0.48}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-fastllm
3
- Version: 0.0.47
3
+ Version: 0.0.48
4
4
  Author-email: Kerem Turgutlu <keremturgutlu@gmail.com>
5
5
  License: Apache-2.0
6
6
  Project-URL: Repository, https://github.com/AnswerDotAI/fastllm
@@ -2,4 +2,4 @@
2
2
 
3
3
  - `fastllm.responses`: Provider-independent Responses resources and streaming over FastLLM"""
4
4
 
5
- __version__ = "0.0.47"
5
+ __version__ = "0.0.48"
@@ -120,7 +120,22 @@ d = { 'settings': { 'branch': 'main',
120
120
  'fastllm.chat.stop_reason': ('chat.html#stop_reason', 'fastllm/chat.py'),
121
121
  'fastllm.chat.structured': ('chat.html#structured', 'fastllm/chat.py'),
122
122
  'fastllm.chat.think_sep': ('chat.html#think_sep', 'fastllm/chat.py')},
123
- 'fastllm.codex': {},
123
+ 'fastllm.codex': { 'fastllm.codex._merge_tokens': ('codex.html#_merge_tokens', 'fastllm/codex.py'),
124
+ 'fastllm.codex._post': ('codex.html#_post', 'fastllm/codex.py'),
125
+ 'fastllm.codex._token_grant': ('codex.html#_token_grant', 'fastllm/codex.py'),
126
+ 'fastllm.codex.codex_auth': ('codex.html#codex_auth', 'fastllm/codex.py'),
127
+ 'fastllm.codex.codex_auth_path': ('codex.html#codex_auth_path', 'fastllm/codex.py'),
128
+ 'fastllm.codex.codex_device_code': ('codex.html#codex_device_code', 'fastllm/codex.py'),
129
+ 'fastllm.codex.codex_device_tokens': ('codex.html#codex_device_tokens', 'fastllm/codex.py'),
130
+ 'fastllm.codex.codex_info': ('codex.html#codex_info', 'fastllm/codex.py'),
131
+ 'fastllm.codex.codex_login': ('codex.html#codex_login', 'fastllm/codex.py'),
132
+ 'fastllm.codex.codex_refresh': ('codex.html#codex_refresh', 'fastllm/codex.py'),
133
+ 'fastllm.codex.codex_token': ('codex.html#codex_token', 'fastllm/codex.py'),
134
+ 'fastllm.codex.codex_tokens': ('codex.html#codex_tokens', 'fastllm/codex.py'),
135
+ 'fastllm.codex.jwt_claims': ('codex.html#jwt_claims', 'fastllm/codex.py'),
136
+ 'fastllm.codex.main': ('codex.html#main', 'fastllm/codex.py'),
137
+ 'fastllm.codex.mk_jwt': ('codex.html#mk_jwt', 'fastllm/codex.py'),
138
+ 'fastllm.codex.token_expired': ('codex.html#token_expired', 'fastllm/codex.py')},
124
139
  'fastllm.gemini': { 'fastllm.gemini._gem_filter_sch': ('gemini.html#_gem_filter_sch', 'fastllm/gemini.py'),
125
140
  'fastllm.gemini._gem_part_type': ('gemini.html#_gem_part_type', 'fastllm/gemini.py'),
126
141
  'fastllm.gemini.acollect_stream': ('gemini.html#acollect_stream', 'fastllm/gemini.py'),
@@ -13,6 +13,7 @@ from fasttransport.core import AsyncHttpCli
13
13
  from fasttransport.errors import APIError
14
14
 
15
15
  from .types import *
16
+ from .codex import codex_auth_path
16
17
  from aidialog.msg_parts import (Part, PartType, Msg, Text, Thinking, ToolUse, ToolResult, InputImage, InputAudio, InputVideo,
17
18
  InputFile, mk_tool_res_msg, Completion)
18
19
  from .streaming import *
@@ -22,7 +23,7 @@ from .anthropic import *
22
23
  from .gemini import *
23
24
 
24
25
  # %% ../nbs/06_acomplete.ipynb #32ee2546
25
- _codex_path = os.getenv('CODEX_AUTH_PATH', '~/.codex/auth.json')
26
+ _codex_path = codex_auth_path()
26
27
  _codex_json = _codex_path, ('tokens','access_token')
27
28
  vendor_mapping = {
28
29
  "openai": ('openai', 'https://api.openai.com/v1', 'OPENAI_API_KEY'),
@@ -162,7 +163,9 @@ async def acomplete(msgs, model, api_name=None, vendor_name=None, api_key=None,
162
163
  if v in vendor_mapping: vendor_name, model = v, m
163
164
  elif v: api_name, model = v, m # a registered transport api (e.g. claude_code): not an HTTP vendor
164
165
  own = api_name in api_registry and not hasattr(api_registry[api_name], 'endpoint') if api_name else False
165
- if own: vendor_name = ifnone(vendor_name, api_name) # an own-transport api: no HTTP client to build
166
+ if own:
167
+ vendor_name = ifnone(vendor_name, api_name) # an own-transport api: no HTTP client to build
168
+ if api_key: kwargs['api_key'] = api_key # its `mk_payload` applies a per-user credential itself
166
169
  else: cli, api_name, vendor_name = mk_client(model=model, vendor_name=vendor_name, api_name=api_name, api_key=api_key, base_url=base_url, xtra_hdrs=xtra_hdrs)
167
170
  api = api_registry[api_name]
168
171
  if previous_response_id is not None:
@@ -0,0 +1,118 @@
1
+ """ChatGPT subscription tokens for the Codex backend"""
2
+
3
+ # AUTOGENERATED! DO NOT EDIT! File to edit: ../nbs/05_codex.ipynb.
4
+
5
+ # %% auto #0
6
+ __all__ = ['SKEW_SECS', 'jwt_claims', 'mk_jwt', 'token_expired', 'codex_tokens', 'codex_info', 'codex_refresh', 'codex_token',
7
+ 'codex_auth_path', 'codex_auth', 'codex_device_code', 'codex_device_tokens', 'codex_login', 'main']
8
+
9
+ # %% ../nbs/05_codex.ipynb #a1d088d2
10
+ import asyncio, base64, json, sys, time, httpx2
11
+ from fastcore.aio import wait_until
12
+ from fastcore.utils import *
13
+
14
+
15
+ # %% ../nbs/05_codex.ipynb #aa7f9d4f
16
+ _CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann'
17
+ _AUTH = 'https://auth.openai.com'
18
+ SKEW_SECS = 60
19
+
20
+ # %% ../nbs/05_codex.ipynb #2008472d
21
+ def jwt_claims(token):
22
+ "The payload of JWT `token`, decoded without verification"
23
+ payload = token.split('.')[1]
24
+ return json.loads(base64.urlsafe_b64decode(payload + '='*(-len(payload)%4)))
25
+
26
+ # %% ../nbs/05_codex.ipynb #fdba8e96
27
+ def mk_jwt(**claims):
28
+ "An unsigned JWT carrying `claims`, shaped like an access token; for examples and tests"
29
+ enc = lambda o: base64.urlsafe_b64encode(json.dumps(o).encode()).decode().rstrip('=')
30
+ return f"{enc(dict(alg='RS256', typ='JWT'))}.{enc(claims)}.sig"
31
+
32
+ # %% ../nbs/05_codex.ipynb #6b6a339d
33
+ def token_expired(token, skew=SKEW_SECS):
34
+ "Is JWT `token` expired, or within `skew` seconds of expiring?"
35
+ try: exp = jwt_claims(token)['exp']
36
+ except (ValueError, KeyError, IndexError): return True
37
+ return time.time() >= exp - skew
38
+
39
+ # %% ../nbs/05_codex.ipynb #099399d5
40
+ def codex_tokens(auth):
41
+ "The `tokens` block of an `auth.json` dict (or an already-unwrapped block), checked for the keys a refresh needs"
42
+ if not isinstance(auth, dict): raise ValueError('Codex auth must be a JSON object')
43
+ tokens = auth.get('tokens', auth)
44
+ missing = [k for k in ('access_token', 'refresh_token') if not tokens.get(k)]
45
+ if missing: raise ValueError(f"Codex auth is missing {', '.join(missing)}")
46
+ return tokens
47
+
48
+ # %% ../nbs/05_codex.ipynb #46864051
49
+ def codex_info(tokens):
50
+ "Plan type and expiry time of the access token in `tokens`"
51
+ claims = jwt_claims(tokens['access_token'])
52
+ return dict(plan=claims.get('https://api.openai.com/auth', {}).get('chatgpt_plan_type'), expires=claims.get('exp'))
53
+
54
+ # %% ../nbs/05_codex.ipynb #28410636
55
+ async def _post(path, form=False, **body):
56
+ "POST `body` to the OpenAI auth server at `path`, as a form or as JSON, returning the JSON reply"
57
+ async with httpx2.AsyncClient() as cli: r = await cli.post(f'{_AUTH}{path}', **(dict(data=body) if form else dict(json=body)))
58
+ r.raise_for_status()
59
+ return r.json()
60
+
61
+ def _merge_tokens(tokens, new): return {**tokens, **{k: new[k] for k in ('access_token', 'id_token', 'refresh_token') if k in new}}
62
+
63
+ async def _token_grant(tokens, form=False, **grant):
64
+ "The token block after `grant` at the token endpoint, merged over `tokens`; the exchange of a sign-in code is form-encoded, a refresh JSON"
65
+ return _merge_tokens(tokens, await _post('/oauth/token', form=form, client_id=_CLIENT_ID, **grant))
66
+
67
+ # %% ../nbs/05_codex.ipynb #48efd465
68
+ async def codex_refresh(tokens):
69
+ "Exchange the refresh token in `tokens` for a new token block"
70
+ return await _token_grant(tokens, grant_type='refresh_token', refresh_token=tokens['refresh_token'], scope='openid profile email')
71
+
72
+ # %% ../nbs/05_codex.ipynb #be590818
73
+ async def codex_token(tokens, force_refresh=False):
74
+ "A valid access token from `tokens`, and the refreshed block to store when a refresh happened (else `None`)"
75
+ if not force_refresh and not token_expired(tokens['access_token']): return tokens['access_token'], None
76
+ tokens = await codex_refresh(tokens)
77
+ return tokens['access_token'], tokens
78
+
79
+ # %% ../nbs/05_codex.ipynb #ea6c1e6a
80
+ def codex_auth_path(): return Path(os.environ.get('CODEX_AUTH_PATH', '~/.codex/auth.json')).expanduser()
81
+
82
+ async def codex_auth(force_refresh=False):
83
+ "A valid access token from the local Codex login, refreshing the file when needed"
84
+ path = codex_auth_path()
85
+ data = json.loads(path.read_text())
86
+ token,new = await codex_token(codex_tokens(data), force_refresh)
87
+ if new: path.write_text(json.dumps({**data, 'tokens':new}, indent=2))
88
+ return token
89
+
90
+ # %% ../nbs/05_codex.ipynb #216c81ae
91
+ _DEVICE = '/api/accounts/deviceauth'
92
+ async def codex_device_code():
93
+ "Start a device sign-in: the page and code to show the user, and what the poll needs"
94
+ r = await _post(f'{_DEVICE}/usercode', client_id=_CLIENT_ID)
95
+ return dict(url=f'{_AUTH}/codex/device', code=r['user_code'], device_auth_id=r['device_auth_id'], interval=int(r.get('interval') or 5))
96
+
97
+ # %% ../nbs/05_codex.ipynb #9ada6ae8
98
+ async def codex_device_tokens(dev, timeout=15*60):
99
+ "Wait for the user to enter the code from `dev`, then exchange the grant for a token block"
100
+ async def _poll():
101
+ try: return await _post(f'{_DEVICE}/token', device_auth_id=dev['device_auth_id'], user_code=dev['code'])
102
+ except httpx2.HTTPStatusError as e:
103
+ if e.response.status_code in (403, 404): return None
104
+ raise
105
+ grant = await wait_until(_poll, 'the sign-in to complete', timeout, sleep=dev['interval'])
106
+ return await _token_grant({}, form=True, grant_type='authorization_code', code=grant['authorization_code'], redirect_uri=f'{_AUTH}/deviceauth/callback',
107
+ code_verifier=grant['code_verifier'])
108
+
109
+ # %% ../nbs/05_codex.ipynb #0ca00e36
110
+ async def codex_login():
111
+ "Sign in to ChatGPT with a device code, returning the token block"
112
+ dev = await codex_device_code()
113
+ print(f"Open {dev['url']} and enter the code {dev['code']}", file=sys.stderr)
114
+ return await codex_device_tokens(dev)
115
+
116
+ def main():
117
+ "`fastllm-auth`: sign in to ChatGPT and print the token block"
118
+ print(json.dumps(asyncio.run(codex_login())))
@@ -148,9 +148,10 @@ class ResponseState(BasicRepr):
148
148
  # %% ../nbs/06a_responses.ipynb #c9ca3744
149
149
  class ResponseTurn(BasicRepr):
150
150
  "A prepared Responses request for one provider call."
151
- def __init__(self, id, created_at, body, history, provider_messages, system='', previous=None, server_tools=None):
151
+ def __init__(self, id, created_at, body, history, provider_messages, system='', previous=None, server_tools=None, call_kwargs=None):
152
152
  store_attr()
153
153
  if server_tools is None: self.server_tools = {}
154
+ if call_kwargs is None: self.call_kwargs = {}
154
155
 
155
156
  @property
156
157
  def provider_previous_id(self): return self.previous.provider_response_id if self.previous else None
@@ -159,7 +160,7 @@ class ResponseTurn(BasicRepr):
159
160
  # %% ../nbs/06a_responses.ipynb #a648297f
160
161
  class AsyncResponses:
161
162
  "Run individual Responses turns over FastLLM."
162
- def prepare(self, body, previous=None, server_tools=None):
163
+ def prepare(self, body, previous=None, server_tools=None, **kwargs):
163
164
  if not isinstance(body, dict): raise ResponsesError('Request body must be an object')
164
165
  model = body.get('model')
165
166
  if not model: raise ResponsesError('model is required', param='model')
@@ -174,13 +175,13 @@ class AsyncResponses:
174
175
  instructions = '\n\n'.join(filter(None, [body.get('instructions', ''), input_system]))
175
176
  provider_messages = new_msgs if previous and previous.provider_response_id else list(history)
176
177
  return ResponseTurn(_new_id('resp'), int(time.time()), body, history, provider_messages, instructions, previous,
177
- {f.__name__: f for f in server_tools or []})
178
+ {f.__name__: f for f in server_tools or []}, kwargs)
178
179
 
179
180
 
180
181
  # %% ../nbs/06a_responses.ipynb #373f7d5e
181
182
  @patch
182
183
  async def call(self:AsyncResponses, turn):
183
- "Start the provider stream for a prepared turn, having received its first chunk"
184
+ "Start the provider stream for a prepared turn, having received its first chunk; the turn's `call_kwargs` reach `acomplete`"
184
185
  body = turn.body
185
186
  reasoning = body.get('reasoning') or {}
186
187
  tools = list(body.get('tools') or []) + [dict(type='function', **get_schema(f, pname='parameters')) for f in turn.server_tools.values()]
@@ -188,7 +189,7 @@ async def call(self:AsyncResponses, turn):
188
189
  kwargs.update(tool_choice=body.get('tool_choice'), parallel_tool_calls=body.get('parallel_tool_calls', True),
189
190
  reasoning_effort=reasoning.get('effort'), max_tokens=body.get('max_output_tokens'), temperature=body.get('temperature'),
190
191
  cache_idxs=body.get('cache_idxs'), ttl=body.get('ttl'), web_search_options=body.get('web_search_options'))
191
- stream = await acomplete(turn.provider_messages, model=body['model'], stream=True, **kwargs)
192
+ stream = await acomplete(turn.provider_messages, model=body['model'], stream=True, **kwargs, **turn.call_kwargs)
192
193
  first = await anext(stream)
193
194
  async def _rest():
194
195
  yield first
@@ -221,7 +222,7 @@ async def server_results(self:AsyncResponses, turn, comp):
221
222
  def server_turn(self:AsyncResponses, turn, comp, results):
222
223
  "The follow-up provider turn answering server tool `results`, within the same public response"
223
224
  outputs = [dict(type='function_call_output', call_id=r.id, output=r.text) for r in results]
224
- nxt = self.prepare(turn.body | dict(input=outputs), self.state(turn, comp), turn.server_tools.values())
225
+ nxt = self.prepare(turn.body | dict(input=outputs), self.state(turn, comp), turn.server_tools.values(), **turn.call_kwargs)
225
226
  nxt.id = turn.id
226
227
  return nxt
227
228
 
@@ -17,6 +17,9 @@ classifiers = [
17
17
  ]
18
18
  dependencies = ['fastcore>=2.1.18', 'aidialog>=0.0.10', 'fasttransport>=0.0.2', 'pillow']
19
19
 
20
+ [project.scripts]
21
+ fastllm-auth = "fastllm.codex:main"
22
+
20
23
  [project.urls]
21
24
  Repository = "https://github.com/AnswerDotAI/fastllm"
22
25
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-fastllm
3
- Version: 0.0.47
3
+ Version: 0.0.48
4
4
  Author-email: Kerem Turgutlu <keremturgutlu@gmail.com>
5
5
  License: Apache-2.0
6
6
  Project-URL: Repository, https://github.com/AnswerDotAI/fastllm
@@ -1,3 +1,6 @@
1
+ [console_scripts]
2
+ fastllm-auth = fastllm.codex:main
3
+
1
4
  [fastaudit_import_allow]
2
5
  fastllm = fastllm
3
6
 
@@ -1,7 +0,0 @@
1
- # AUTOGENERATED! DO NOT EDIT! File to edit: ../nbs/05_codex.ipynb.
2
-
3
- # %% auto #0
4
- __all__ = []
5
-
6
- # %% ../nbs/05_codex.ipynb #a1d088d2
7
- from fastcore.utils import *