python-fastllm 0.0.47__tar.gz → 0.0.49__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.49}/PKG-INFO +1 -1
  2. {python_fastllm-0.0.47 → python_fastllm-0.0.49}/fastllm/__init__.py +1 -1
  3. {python_fastllm-0.0.47 → python_fastllm-0.0.49}/fastllm/_modidx.py +17 -2
  4. {python_fastllm-0.0.47 → python_fastllm-0.0.49}/fastllm/acomplete.py +15 -11
  5. {python_fastllm-0.0.47 → python_fastllm-0.0.49}/fastllm/anthropic.py +6 -2
  6. {python_fastllm-0.0.47 → python_fastllm-0.0.49}/fastllm/chat.py +3 -1
  7. python_fastllm-0.0.49/fastllm/codex.py +118 -0
  8. {python_fastllm-0.0.47 → python_fastllm-0.0.49}/fastllm/gemini.py +4 -1
  9. {python_fastllm-0.0.47 → python_fastllm-0.0.49}/fastllm/openai_chat.py +4 -1
  10. {python_fastllm-0.0.47 → python_fastllm-0.0.49}/fastllm/openai_responses.py +4 -1
  11. {python_fastllm-0.0.47 → python_fastllm-0.0.49}/fastllm/responses.py +7 -6
  12. {python_fastllm-0.0.47 → python_fastllm-0.0.49}/fastllm/types.py +6 -5
  13. {python_fastllm-0.0.47 → python_fastllm-0.0.49}/pyproject.toml +3 -0
  14. {python_fastllm-0.0.47 → python_fastllm-0.0.49}/python_fastllm.egg-info/PKG-INFO +1 -1
  15. {python_fastllm-0.0.47 → python_fastllm-0.0.49}/python_fastllm.egg-info/entry_points.txt +3 -0
  16. python_fastllm-0.0.47/fastllm/codex.py +0 -7
  17. {python_fastllm-0.0.47 → python_fastllm-0.0.49}/README.md +0 -0
  18. {python_fastllm-0.0.47 → python_fastllm-0.0.49}/fastllm/streaming.py +0 -0
  19. {python_fastllm-0.0.47 → python_fastllm-0.0.49}/python_fastllm.egg-info/SOURCES.txt +0 -0
  20. {python_fastllm-0.0.47 → python_fastllm-0.0.49}/python_fastllm.egg-info/dependency_links.txt +0 -0
  21. {python_fastllm-0.0.47 → python_fastllm-0.0.49}/python_fastllm.egg-info/requires.txt +0 -0
  22. {python_fastllm-0.0.47 → python_fastllm-0.0.49}/python_fastllm.egg-info/top_level.txt +0 -0
  23. {python_fastllm-0.0.47 → python_fastllm-0.0.49}/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.49
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.49"
@@ -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'),
@@ -323,7 +338,7 @@ d = { 'settings': { 'branch': 'main',
323
338
  'fastllm.types.Usage.__add__': ('types.html#usage.__add__', 'fastllm/types.py'),
324
339
  'fastllm.types.Usage.__eq__': ('types.html#usage.__eq__', 'fastllm/types.py'),
325
340
  'fastllm.types.Usage.__init__': ('types.html#usage.__init__', 'fastllm/types.py'),
326
- 'fastllm.types._rm_ctx_tiers': ('types.html#_rm_ctx_tiers', 'fastllm/types.py'),
341
+ 'fastllm.types._flat_rates': ('types.html#_flat_rates', 'fastllm/types.py'),
327
342
  'fastllm.types.approx_pricing': ('types.html#approx_pricing', 'fastllm/types.py'),
328
343
  'fastllm.types.effort_code': ('types.html#effort_code', 'fastllm/types.py'),
329
344
  'fastllm.types.effort_levels': ('types.html#effort_levels', 'fastllm/types.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'),
@@ -66,10 +67,10 @@ def _codex_mtime_policy():
66
67
 
67
68
  # %% ../nbs/06_acomplete.ipynb #79075d95
68
69
  @flexicache(_codex_mtime_policy())
69
- def mk_client(model=None, vendor_name=None, api_name=None, api_key=None, base_url=None, xtra_hdrs=None,
70
+ def mk_client(model=None, vendor_name=None, api_name=None, api_key=None, oauth_token=None, base_url=None, xtra_hdrs=None,
70
71
  timeout=httpx2.Timeout(connect=30, read=300, write=30, pool=10)):
71
72
  err_msg = f"please pass a valid one vendor: {', '.join(list(vendor_mapping))} or pass `api_name`,`base_url` and `api_key`"
72
- if not vendor_name and not api_name and not (base_url and api_key):
73
+ if not vendor_name and not api_name and not (base_url and (api_key or oauth_token)):
73
74
  v, m = split_vendor(model)
74
75
  if v in vendor_mapping: vendor_name, model = v, m
75
76
  if vendor_name:
@@ -77,17 +78,17 @@ def mk_client(model=None, vendor_name=None, api_name=None, api_key=None, base_ur
77
78
  try:
78
79
  api_name, base_url, env_api_nm, *auth_json = vendor_mapping[vendor_name]
79
80
  base_url = override_base_url or base_url
80
- if auth_json and not api_key and not os.getenv(env_api_nm):
81
+ if auth_json and not api_key and not oauth_token and not os.getenv(env_api_nm):
81
82
  fn,keys = auth_json[0] # pyright: ignore[reportAssignmentType]
82
83
  auth_fn = Path(fn).expanduser()
83
84
  if auth_fn.exists(): api_key = nested_idx(json.loads(auth_fn.read_text()), *keys)
84
- api_key = get_api_key(api_key, env_api_nm)
85
+ if not oauth_token: api_key = get_api_key(api_key, env_api_nm)
85
86
  except KeyError: raise ValueError(f"Unknown vendor '{vendor_name}', {err_msg}")
86
- elif base_url and api_key: vendor_name, api_name = ifnone(vendor_name, 'custom'), ifnone(api_name, 'openai_chat')
87
+ elif base_url and (api_key or oauth_token): vendor_name, api_name = ifnone(vendor_name, 'custom'), ifnone(api_name, 'openai_chat')
87
88
  elif (api_name:=infer_api_name(model)): base_url, vendor_name = vendor_mapping[api_name][1], api_name
88
89
  else: raise ValueError(f"Model {model} can't be auto resolved, {err_msg}")
89
90
  api = api_registry[api_name]
90
- hdrs = merge(api.get_hdrs(api_key), ifnone(xtra_hdrs, {}))
91
+ hdrs = merge(api.get_hdrs(api_key, oauth_token), ifnone(xtra_hdrs, {}))
91
92
  cli = AsyncHttpCli(base_url, base_headers=hdrs, timeout=timeout)
92
93
  return cli, api_name, vendor_name
93
94
 
@@ -154,16 +155,19 @@ async def _retry_stream(mk_gen, retries=2, retry_delay=0.5):
154
155
 
155
156
  # %% ../nbs/06_acomplete.ipynb #2379ec94
156
157
  @delegates(payload_kwargs)
157
- async def acomplete(msgs, model, api_name=None, vendor_name=None, api_key=None, base_url=None, endpoint=None, xtra_body=None, xtra_hdrs=None,
158
+ async def acomplete(msgs, model, api_name=None, vendor_name=None, api_key=None, oauth_token=None, base_url=None, endpoint=None, xtra_body=None, xtra_hdrs=None,
158
159
  stream=False, previous_response_id=None, stop_callables=None, retries=2, retry_delay=0.5, **kwargs):
159
160
  "Unified completion across different APIs."
160
- if not vendor_name and not api_name and not (base_url and api_key):
161
+ if api_key and oauth_token: raise ValueError('Pass `api_key` or `oauth_token`, not both: a request has one identity')
162
+ if not vendor_name and not api_name and not (base_url and (api_key or oauth_token)):
161
163
  v, m = split_vendor(model)
162
164
  if v in vendor_mapping: vendor_name, model = v, m
163
165
  elif v: api_name, model = v, m # a registered transport api (e.g. claude_code): not an HTTP vendor
164
166
  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
- 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
+ if own:
168
+ vendor_name = ifnone(vendor_name, api_name) # an own-transport api: no HTTP client to build
169
+ if oauth_token: kwargs['oauth_token'] = oauth_token # its `mk_payload` applies the user's token itself
170
+ else: cli, api_name, vendor_name = mk_client(model=model, vendor_name=vendor_name, api_name=api_name, api_key=api_key, oauth_token=oauth_token, base_url=base_url, xtra_hdrs=xtra_hdrs)
167
171
  api = api_registry[api_name]
168
172
  if previous_response_id is not None:
169
173
  if not getattr(api, 'supports_previous_response_id', False):
@@ -322,8 +322,12 @@ def mk_payload(msgs, model, **kwargs):
322
322
  return payload
323
323
 
324
324
  # %% ../nbs/04_anthropic.ipynb #60d52c1f
325
- def get_hdrs(api_key=None):
326
- return {"x-api-key": get_api_key(api_key, 'ANTHROPIC_API_KEY'), "anthropic-version": "2023-06-01"}
325
+ def get_hdrs(api_key=None, oauth_token=None):
326
+ "An OAuth token travels as a bearer under Anthropic's OAuth beta; an API key as `x-api-key`"
327
+ hdrs = {"anthropic-version": "2023-06-01"}
328
+ if oauth_token: return hdrs | {"Authorization": f"Bearer {oauth_token}", "anthropic-beta": "oauth-2025-04-20"}
329
+ return hdrs | {"x-api-key": get_api_key(api_key, 'ANTHROPIC_API_KEY')}
330
+
327
331
 
328
332
  # %% ../nbs/04_anthropic.ipynb #0d03643a
329
333
  def cost(usage, m):
@@ -168,6 +168,7 @@ class AsyncChat:
168
168
  api_name=None, # API to use, one of ApiName: openai (responses), openai_chat, anthropic, gemini
169
169
  vendor_name=None, # Vendor name, one of vendor_mapping which resolves api_base/api_key automatically
170
170
  api_key=None, # API key when model can't be resolved or vendor_name is not known or codex
171
+ oauth_token=None, # A subscription's OAuth token (Codex, Claude Code) in place of an API key
171
172
  base_url=None, # API base url when model can't be resolved or vendor_name is not known
172
173
  endpoint=None, # Override the transport's request path, for a server mounting Responses at a custom location
173
174
  extra_headers=None, # Extra HTTP headers for custom providers
@@ -178,7 +179,7 @@ class AsyncChat:
178
179
  default_cbs=True # Whether to include default callbacks
179
180
  ):
180
181
  "LiteLLM chat client."
181
- if not vendor_name and not api_name and not (base_url and api_key):
182
+ if not vendor_name and not api_name and not (base_url and (api_key or oauth_token)):
182
183
  v, model = split_vendor(model)
183
184
  if v in vendor_mapping: vendor_name = v
184
185
  elif v: api_name = v # a registered transport api (e.g. claude_code)
@@ -248,6 +249,7 @@ def _prep_call(self:AsyncChat, search, max_tokens, kwargs, stream=False, think=N
248
249
  if self.api_name: kwargs['api_name'] = self.api_name
249
250
  if self.vendor_name: kwargs['vendor_name'] = self.vendor_name
250
251
  if self.api_key: kwargs['api_key'] = self.api_key
252
+ if self.oauth_token: kwargs['oauth_token'] = self.oauth_token
251
253
  if self.base_url: kwargs['base_url'] = self.base_url
252
254
  if self.endpoint: kwargs['endpoint'] = self.endpoint
253
255
  if self.extra_headers: kwargs['xtra_hdrs'] = self.extra_headers
@@ -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())))
@@ -267,9 +267,12 @@ def mk_payload(msgs, model, **kwargs):
267
267
  return payload
268
268
 
269
269
  # %% ../nbs/05_gemini.ipynb #60d52c1f
270
- def get_hdrs(api_key=None):
270
+ def get_hdrs(api_key=None, oauth_token=None):
271
+ "Bearer auth for an OAuth token; otherwise the API key header"
272
+ if oauth_token: return {"Authorization": f"Bearer {oauth_token}"}
271
273
  return {"x-goog-api-key": get_api_key(api_key, 'GEMINI_API_KEY')}
272
274
 
275
+
273
276
  # %% ../nbs/05_gemini.ipynb #4ee3891f
274
277
  def cost(usage, m):
275
278
  raw = usage.raw
@@ -198,7 +198,10 @@ def mk_payload(msgs, model, **kwargs):
198
198
  return payload
199
199
 
200
200
  # %% ../nbs/03_oai_chat.ipynb #16e813d2
201
- def get_hdrs(api_key=None): return {"Authorization": f"Bearer {get_api_key(api_key, 'OPENAI_API_KEY')}"}
201
+ def get_hdrs(api_key=None, oauth_token=None):
202
+ "Bearer auth: an OAuth access token, or else an API key from the argument or the environment"
203
+ return {"Authorization": f"Bearer {oauth_token or get_api_key(api_key, 'OPENAI_API_KEY')}"}
204
+
202
205
 
203
206
  # %% ../nbs/03_oai_chat.ipynb #f89e2bf6
204
207
  def cost(usage, m):
@@ -256,7 +256,10 @@ def mk_payload(msgs, model, **kwargs):
256
256
  return payload
257
257
 
258
258
  # %% ../nbs/02_oai_responses.ipynb #529fd4bc
259
- def get_hdrs(api_key=None): return {"Authorization": f"Bearer {get_api_key(api_key, 'OPENAI_API_KEY')}"}
259
+ def get_hdrs(api_key=None, oauth_token=None):
260
+ "Bearer auth: an OAuth access token, or else an API key from the argument or the environment"
261
+ return {"Authorization": f"Bearer {oauth_token or get_api_key(api_key, 'OPENAI_API_KEY')}"}
262
+
260
263
 
261
264
  # %% ../nbs/02_oai_responses.ipynb #a907ffa8
262
265
  def cost(usage, m):
@@ -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
 
@@ -300,14 +300,14 @@ register_model_info('muse-spark-1.1', vendor_name='meta_ai', **modern_llm, max_i
300
300
  codex_pricing = dict(input_cost_per_token = 0.10/1_000_000, output_cost_per_token = 0.50/1_000_000,
301
301
  cache_creation_input_token_cost = 0.10/1_000_000, cache_read_input_token_cost = 0.10/1_000_000)
302
302
 
303
- def _rm_ctx_tiers(vendor_name, model):
304
- "Drop upstream context-tier rates, which would otherwise override flat subscription pricing"
303
+ def _flat_rates(vendor_name, model):
304
+ "Keep only the flat subscription rates: drop the upstream context, cache-TTL and service-tier variants that would otherwise apply"
305
305
  info = model_info_registry[vendor_name, model]
306
- for k in [k for k in info if re.search(r'_above_\d+k_tokens', k)]: del info[k]
306
+ for k in [k for k in info if 'cost' in k and k not in codex_pricing and k != 'search_context_cost_per_query']: del info[k]
307
307
 
308
308
  for model in (codex54, codex54m, codex55):
309
309
  register_model_info(model, 'codex', base=model, base_vendor_name='chatgpt', supports_web_search=True, max_input_tokens=256000, **codex_pricing)
310
- _rm_ctx_tiers('codex', model)
310
+ _flat_rates('codex', model)
311
311
 
312
312
  register_model_info(codex53spark, 'codex', **codex_pricing,
313
313
  supports_vision=False, supports_image_input=False, supports_web_search=True, supports_reasoning=True, supports_function_calling=True,
@@ -318,6 +318,7 @@ register_model_info(codex53spark, 'codex', **codex_pricing,
318
318
  register_model_info(fable51, 'anthropic', base=fable5)
319
319
  for model in (haik45, sonn45, sonn46, sonn5, opus46, opus48, opus5, fable5, fable51):
320
320
  register_model_info(model, 'claude_code', base=model, base_vendor_name='anthropic', **codex_pricing)
321
+ _flat_rates('claude_code', model)
321
322
 
322
323
  # %% ../nbs/00_types.ipynb #bb0c4c2a
323
324
  sol,terra,luna = gpt56s = 'gpt-5.6-sol gpt-5.6-terra gpt-5.6-luna'.split()
@@ -325,7 +326,7 @@ sol,terra,luna = gpt56s = 'gpt-5.6-sol gpt-5.6-terra gpt-5.6-luna'.split()
325
326
  # Its window is smaller than the API's: 371,331 input tokens is accepted and 371,981 is not, on all three.
326
327
  for model in gpt56s:
327
328
  register_model_info(model, 'codex', base=model, base_vendor_name='openai', max_input_tokens=371_000, **codex_pricing)
328
- _rm_ctx_tiers('codex', model)
329
+ _flat_rates('codex', model)
329
330
 
330
331
  # %% ../nbs/00_types.ipynb #24cc47ec
331
332
  def get_model_pricing(mn, vendor_name, million=True):
@@ -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.49
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 *