python-fastllm 0.0.46__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.46 → python_fastllm-0.0.48}/PKG-INFO +1 -1
  2. {python_fastllm-0.0.46 → python_fastllm-0.0.48}/fastllm/__init__.py +1 -1
  3. {python_fastllm-0.0.46 → python_fastllm-0.0.48}/fastllm/_modidx.py +24 -1
  4. {python_fastllm-0.0.46 → python_fastllm-0.0.48}/fastllm/acomplete.py +5 -2
  5. {python_fastllm-0.0.46 → python_fastllm-0.0.48}/fastllm/chat.py +5 -11
  6. python_fastllm-0.0.48/fastllm/codex.py +118 -0
  7. {python_fastllm-0.0.46 → python_fastllm-0.0.48}/fastllm/openai_responses.py +1 -0
  8. {python_fastllm-0.0.46 → python_fastllm-0.0.48}/fastllm/responses.py +69 -23
  9. {python_fastllm-0.0.46 → python_fastllm-0.0.48}/fastllm/types.py +53 -55
  10. {python_fastllm-0.0.46 → python_fastllm-0.0.48}/pyproject.toml +3 -0
  11. {python_fastllm-0.0.46 → python_fastllm-0.0.48}/python_fastllm.egg-info/PKG-INFO +1 -1
  12. {python_fastllm-0.0.46 → python_fastllm-0.0.48}/python_fastllm.egg-info/entry_points.txt +3 -0
  13. python_fastllm-0.0.46/fastllm/codex.py +0 -7
  14. {python_fastllm-0.0.46 → python_fastllm-0.0.48}/README.md +0 -0
  15. {python_fastllm-0.0.46 → python_fastllm-0.0.48}/fastllm/anthropic.py +0 -0
  16. {python_fastllm-0.0.46 → python_fastllm-0.0.48}/fastllm/gemini.py +0 -0
  17. {python_fastllm-0.0.46 → python_fastllm-0.0.48}/fastllm/openai_chat.py +0 -0
  18. {python_fastllm-0.0.46 → python_fastllm-0.0.48}/fastllm/streaming.py +0 -0
  19. {python_fastllm-0.0.46 → python_fastllm-0.0.48}/python_fastllm.egg-info/SOURCES.txt +0 -0
  20. {python_fastllm-0.0.46 → python_fastllm-0.0.48}/python_fastllm.egg-info/dependency_links.txt +0 -0
  21. {python_fastllm-0.0.46 → python_fastllm-0.0.48}/python_fastllm.egg-info/requires.txt +0 -0
  22. {python_fastllm-0.0.46 → python_fastllm-0.0.48}/python_fastllm.egg-info/top_level.txt +0 -0
  23. {python_fastllm-0.0.46 → 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.46
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.46"
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'),
@@ -237,6 +252,10 @@ d = { 'settings': { 'branch': 'main',
237
252
  'fastllm/responses.py'),
238
253
  'fastllm.responses.AsyncResponses.prepare': ( 'responses.html#asyncresponses.prepare',
239
254
  'fastllm/responses.py'),
255
+ 'fastllm.responses.AsyncResponses.server_results': ( 'responses.html#asyncresponses.server_results',
256
+ 'fastllm/responses.py'),
257
+ 'fastllm.responses.AsyncResponses.server_turn': ( 'responses.html#asyncresponses.server_turn',
258
+ 'fastllm/responses.py'),
240
259
  'fastllm.responses.AsyncResponses.state': ( 'responses.html#asyncresponses.state',
241
260
  'fastllm/responses.py'),
242
261
  'fastllm.responses.ResponseState': ('responses.html#responsestate', 'fastllm/responses.py'),
@@ -316,10 +335,13 @@ d = { 'settings': { 'branch': 'main',
316
335
  'fastllm.types.Completion.cost': ('types.html#completion.cost', 'fastllm/types.py'),
317
336
  'fastllm.types.Completion.formatted': ('types.html#completion.formatted', 'fastllm/types.py'),
318
337
  'fastllm.types.Usage': ('types.html#usage', 'fastllm/types.py'),
338
+ 'fastllm.types.Usage.__add__': ('types.html#usage.__add__', 'fastllm/types.py'),
319
339
  'fastllm.types.Usage.__eq__': ('types.html#usage.__eq__', 'fastllm/types.py'),
320
340
  'fastllm.types.Usage.__init__': ('types.html#usage.__init__', 'fastllm/types.py'),
321
341
  'fastllm.types._rm_ctx_tiers': ('types.html#_rm_ctx_tiers', 'fastllm/types.py'),
322
342
  'fastllm.types.approx_pricing': ('types.html#approx_pricing', 'fastllm/types.py'),
343
+ 'fastllm.types.effort_code': ('types.html#effort_code', 'fastllm/types.py'),
344
+ 'fastllm.types.effort_levels': ('types.html#effort_levels', 'fastllm/types.py'),
323
345
  'fastllm.types.fn_schema': ('types.html#fn_schema', 'fastllm/types.py'),
324
346
  'fastllm.types.get_api_key': ('types.html#get_api_key', 'fastllm/types.py'),
325
347
  'fastllm.types.get_model_info': ('types.html#get_model_info', 'fastllm/types.py'),
@@ -334,6 +356,7 @@ d = { 'settings': { 'branch': 'main',
334
356
  'fastllm.types.provider_req': ('types.html#provider_req', 'fastllm/types.py'),
335
357
  'fastllm.types.register_model_info': ('types.html#register_model_info', 'fastllm/types.py'),
336
358
  'fastllm.types.resize_b64': ('types.html#resize_b64', 'fastllm/types.py'),
359
+ 'fastllm.types.resolve_effort': ('types.html#resolve_effort', 'fastllm/types.py'),
337
360
  'fastllm.types.tier_rate': ('types.html#tier_rate', 'fastllm/types.py'),
338
361
  'fastllm.types.unwrap_typed': ('types.html#unwrap_typed', 'fastllm/types.py'),
339
362
  'fastllm.types.wrap_typed': ('types.html#wrap_typed', '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'),
@@ -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:
@@ -225,21 +225,15 @@ class AsyncChat:
225
225
  def _usrtools(tcs): return L(tcs).filter(lambda o: not o.server) if tcs else None
226
226
 
227
227
  # %% ../nbs/07_chat.ipynb #19b87f53
228
- _effort_names = dict(none='n', low='l', medium='m', high='h', xhigh='x', max='x')
229
-
230
228
  def _think_kw(model, think, vendor_name):
231
- think = _effort_names.get(think, think)
232
- if not think: return {}
233
- if 'opus-4-7' in model:
234
- e = 'xhigh' if think=='h' else effort.get(think)
235
- eff = dict(thinking={"type":"adaptive", "display":"summarized"}, output_config={"effort":e})
236
- return dict(reasoning_effort=eff)
237
- try: xhigh = get_model_info(model, vendor_name).get('supports_xhigh_reasoning_effort')
238
- except: xhigh = False
239
- eff = effort.get(think) if think!='x' else 'xhigh' if xhigh else 'high'
229
+ if think is None or think == '': return {}
230
+ eff = resolve_effort(effort_levels(get_model_info(model, vendor_name)), think)
231
+ if eff is None: return {}
232
+ if 'opus-4-7' in model: return dict(reasoning_effort=dict(thinking={"type":"adaptive", "display":"summarized"}, output_config={"effort":eff}))
240
233
  if vendor_name == 'codex': return dict(reasoning_effort={'effort':eff, 'summary':'auto'})
241
234
  return dict(reasoning_effort=eff)
242
235
 
236
+
243
237
  # %% ../nbs/07_chat.ipynb #06e898fd
244
238
  @patch
245
239
  def _prep_call(self:AsyncChat, search, max_tokens, kwargs, stream=False, think=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())))
@@ -186,6 +186,7 @@ def denorm_tool_choice(v):
186
186
  def denorm_reasoning(v):
187
187
  "Map canonical reasoning_effort to OpenAI Responses reasoning param."
188
188
  if v is None: return None
189
+ if isinstance(v, int): return {'level': v} # a 0-3 level, for a Responses server that resolves it per model
189
190
  return {'effort': v} if isinstance(v, str) else v
190
191
 
191
192
  # %% ../nbs/02_oai_responses.ipynb #e11c15c0
@@ -15,13 +15,14 @@ __all__ = ['ResponsesError', 'response_input', 'normalize_call_ids', 'response_o
15
15
  'ResponseState', 'ResponseTurn', 'AsyncResponses', 'response_event', 'pending_response']
16
16
 
17
17
  # %% ../nbs/06a_responses.ipynb #e16fc24e
18
- import asyncio, json, secrets, time
18
+ import asyncio, copy, json, secrets, time
19
19
  from fastcore.utils import *
20
+ from fastcore.funccall import call_func_async, get_schema
20
21
  from fasttransport.errors import APIError
21
22
 
22
23
  from .acomplete import acomplete
23
24
  from .streaming import Status
24
- from aidialog.msg_parts import Msg, Text, ToolUse, ToolResult, InputImage, InputFile, Completion
25
+ from aidialog.msg_parts import Msg, Text, ToolUse, ToolResult, InputImage, InputFile, Completion, tool_text
25
26
 
26
27
  # %% ../nbs/06a_responses.ipynb #f904c16f
27
28
  class ResponsesError(Exception):
@@ -69,7 +70,9 @@ def response_input(inp):
69
70
  if isinstance(args, str):
70
71
  try: args = json.loads(args)
71
72
  except json.JSONDecodeError: pass
72
- msgs.append(Msg('assistant', [ToolUse(id=item.get('call_id'), name=item.get('name'), arguments=args)]))
73
+ call = ToolUse(id=item.get('call_id'), name=item.get('name'), arguments=args)
74
+ if msgs and msgs[-1].role == 'assistant': msgs[-1].content.append(call)
75
+ else: msgs.append(Msg('assistant', [call]))
73
76
  elif typ == 'function_call_output':
74
77
  out = item.get('output', '')
75
78
  pending_tools.append(ToolResult(id=item.get('call_id'), name=item.get('name', ''),
@@ -145,15 +148,19 @@ class ResponseState(BasicRepr):
145
148
  # %% ../nbs/06a_responses.ipynb #c9ca3744
146
149
  class ResponseTurn(BasicRepr):
147
150
  "A prepared Responses request for one provider call."
148
- def __init__(self, id, created_at, body, history, provider_messages, system='', previous=None): store_attr()
151
+ def __init__(self, id, created_at, body, history, provider_messages, system='', previous=None, server_tools=None, call_kwargs=None):
152
+ store_attr()
153
+ if server_tools is None: self.server_tools = {}
154
+ if call_kwargs is None: self.call_kwargs = {}
149
155
 
150
156
  @property
151
157
  def provider_previous_id(self): return self.previous.provider_response_id if self.previous else None
152
158
 
159
+
153
160
  # %% ../nbs/06a_responses.ipynb #a648297f
154
161
  class AsyncResponses:
155
162
  "Run individual Responses turns over FastLLM."
156
- def prepare(self, body, previous=None):
163
+ def prepare(self, body, previous=None, server_tools=None, **kwargs):
157
164
  if not isinstance(body, dict): raise ResponsesError('Request body must be an object')
158
165
  model = body.get('model')
159
166
  if not model: raise ResponsesError('model is required', param='model')
@@ -167,27 +174,57 @@ class AsyncResponses:
167
174
  if isinstance(p, ToolResult) and not p.name: p.name = names.get(p.id, '')
168
175
  instructions = '\n\n'.join(filter(None, [body.get('instructions', ''), input_system]))
169
176
  provider_messages = new_msgs if previous and previous.provider_response_id else list(history)
170
- return ResponseTurn(_new_id('resp'), int(time.time()), body, history, provider_messages, instructions, previous)
177
+ return ResponseTurn(_new_id('resp'), int(time.time()), body, history, provider_messages, instructions, previous,
178
+ {f.__name__: f for f in server_tools or []}, kwargs)
179
+
171
180
 
172
181
  # %% ../nbs/06a_responses.ipynb #373f7d5e
173
182
  @patch
174
183
  async def call(self:AsyncResponses, turn):
175
- "Start the provider stream for a prepared turn."
184
+ "Start the provider stream for a prepared turn, having received its first chunk; the turn's `call_kwargs` reach `acomplete`"
176
185
  body = turn.body
177
186
  reasoning = body.get('reasoning') or {}
178
- kwargs = dict(previous_response_id=turn.provider_previous_id, system=turn.system or None, tools=body.get('tools'))
187
+ tools = list(body.get('tools') or []) + [dict(type='function', **get_schema(f, pname='parameters')) for f in turn.server_tools.values()]
188
+ kwargs = dict(previous_response_id=turn.provider_previous_id, system=turn.system or None, tools=tools or None)
179
189
  kwargs.update(tool_choice=body.get('tool_choice'), parallel_tool_calls=body.get('parallel_tool_calls', True),
180
190
  reasoning_effort=reasoning.get('effort'), max_tokens=body.get('max_output_tokens'), temperature=body.get('temperature'),
181
191
  cache_idxs=body.get('cache_idxs'), ttl=body.get('ttl'), web_search_options=body.get('web_search_options'))
182
- return 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)
193
+ first = await anext(stream)
194
+ async def _rest():
195
+ yield first
196
+ async for o in stream: yield o
197
+ return _rest()
198
+
183
199
 
184
200
  # %% ../nbs/06a_responses.ipynb #4f424285
185
201
  @patch
186
- def state(self:AsyncResponses, turn, comp):
187
- "Build the next continuation state."
202
+ def state(self:AsyncResponses, turn, comp, results=()):
203
+ "Build the next continuation state; `results` are server tool results answering `comp`'s calls."
188
204
  normalize_call_ids(comp)
189
- return ResponseState(turn.id, turn.body['model'], (*turn.history, comp.message),
190
- comp.response_id, comp.response_id_reusable)
205
+ history = (*turn.history, comp.message, *([Msg('tool', list(results))] if results else []))
206
+ return ResponseState(turn.id, turn.body['model'], history, comp.response_id, comp.response_id_reusable)
207
+
208
+
209
+ # %% ../nbs/06a_responses.ipynb #7615b274
210
+ @patch
211
+ async def server_results(self:AsyncResponses, turn, comp):
212
+ "Run the server tool calls in `comp`, returning their results"
213
+ normalize_call_ids(comp)
214
+ res = []
215
+ for tc in comp.tool_calls:
216
+ if tc.name not in turn.server_tools: continue
217
+ out = await call_func_async(tc.name, tc.arguments, ns=turn.server_tools, raise_on_err=False)
218
+ res.append(ToolResult(id=tc.id, name=tc.name, arguments=tc.arguments, text=tool_text(out)))
219
+ return res
220
+
221
+ @patch
222
+ def server_turn(self:AsyncResponses, turn, comp, results):
223
+ "The follow-up provider turn answering server tool `results`, within the same public response"
224
+ outputs = [dict(type='function_call_output', call_id=r.id, output=r.text) for r in results]
225
+ nxt = self.prepare(turn.body | dict(input=outputs), self.state(turn, comp), turn.server_tools.values(), **turn.call_kwargs)
226
+ nxt.id = turn.id
227
+ return nxt
191
228
 
192
229
  # %% ../nbs/06a_responses.ipynb #25502099
193
230
  def response_event(event_type, sequence_number, **kwargs):
@@ -278,22 +315,31 @@ def done_events(self:_ResponseStream, comp):
278
315
 
279
316
  # %% ../nbs/06a_responses.ipynb #e10c928b
280
317
  @patch
281
- async def events(self:AsyncResponses, turn, finalize=None):
282
- state = _ResponseStream(turn)
318
+ async def events(self:AsyncResponses, turn, finalize=None, stream=None):
319
+ state,first = _ResponseStream(turn),turn
283
320
  yield state.event('response.created', response=pending_response(turn))
284
321
  try:
285
- stream = await self.call(turn)
286
- async for part in stream:
287
- if isinstance(part, Status): continue
288
- if isinstance(part, Completion): comp = part
289
- else:
290
- for event in state.part_events(part): yield event
322
+ texts,usage = [],None
323
+ while True:
324
+ async for part in (stream or await self.call(turn)):
325
+ if isinstance(part, Completion): comp = part
326
+ elif isinstance(part, Status) or (isinstance(part, ToolUse) and part.name in turn.server_tools): continue
327
+ else:
328
+ for event in state.part_events(part): yield event
329
+ usage,stream = (comp.usage if usage is None else usage + comp.usage),None
330
+ results = await self.server_results(turn, comp)
331
+ if not results or len(results) < len(comp.tool_calls): break
332
+ texts += [p for p in comp.message.content if isinstance(p, Text)]
333
+ turn = self.server_turn(turn, comp, results)
334
+ next_state = self.state(turn, comp, results)
335
+ comp = copy.copy(comp)
336
+ comp.message,comp.usage = Msg('assistant', texts + comp.message.content),usage
291
337
  for event in state.done_events(comp): yield event
292
- next_state = self.state(turn, comp)
293
- response = response_object(turn.id, turn.body, comp, turn.previous.id if turn.previous else None, turn.created_at)
338
+ response = response_object(first.id, first.body, comp, first.previous.id if first.previous else None, first.created_at)
294
339
  if finalize: await finalize(next_state, comp)
295
340
  name = 'response.incomplete' if response['status'] == 'incomplete' else 'response.completed'
296
341
  yield state.event(name, response=response)
297
342
  except asyncio.CancelledError: raise
298
343
  except ResponsesError as exc: yield state.event('error', code=exc.code, message=str(exc), param=exc.param)
299
344
  except APIError as exc: yield state.event('error', code=exc.code or 'provider_error', message=str(exc), param=None)
345
+
@@ -3,13 +3,14 @@
3
3
  # AUTOGENERATED! DO NOT EDIT! File to edit: ../nbs/00_types.ipynb.
4
4
 
5
5
  # %% auto #0
6
- __all__ = ['FinishReason', 'api_registry', 'model_prices_url', 'haik45', 'sonn45', 'sonn46', 'sonn', 'sonn5', 'opus46', 'opus48',
7
- 'opus', 'opus5', 'fable', 'fable5', 'fable51', 'gpt54', 'gpt54m', 'gpt55', 'codex54', 'codex54m', 'codex55',
8
- 'codex53spark', 'model_info_registry', 'modern_llm', 'deepseek_v4_common', 'deepseek_v4_flash_prices',
9
- 'mimo_v25_common', 'codex_pricing', 'sol', 'terra', 'luna', 'gpt56s', 'Usage', 'APIRegistry',
10
- 'mk_completion', 'fn_schema', 'payload_kwargs', 'provider_req', 'get_api_key', 'wrap_typed', 'unwrap_typed',
11
- 'resize_b64', 'model_prices_meta', 'infer_api_name', 'get_model_meta', 'register_model_info',
12
- 'get_model_info', 'get_model_pricing', 'approx_pricing', 'is_deepseek_peak_hour', 'price_tier', 'tier_rate']
6
+ __all__ = ['FinishReason', 'api_registry', 'model_prices_url', 'sample_img_url', 'haik45', 'sonn45', 'sonn46', 'sonn', 'sonn5',
7
+ 'opus46', 'opus48', 'opus', 'opus5', 'fable', 'fable5', 'fable51', 'gpt54', 'gpt54m', 'gpt55', 'codex54',
8
+ 'codex54m', 'codex55', 'codex53spark', 'model_info_registry', 'modern_llm', 'effort_codes',
9
+ 'deepseek_v4_common', 'deepseek_v4_flash_prices', 'mimo_v25_common', 'codex_pricing', 'sol', 'terra', 'luna',
10
+ 'gpt56s', 'Usage', 'APIRegistry', 'mk_completion', 'fn_schema', 'payload_kwargs', 'provider_req',
11
+ 'get_api_key', 'wrap_typed', 'unwrap_typed', 'resize_b64', 'model_prices_meta', 'infer_api_name',
12
+ 'get_model_meta', 'register_model_info', 'get_model_info', 'effort_code', 'effort_levels', 'resolve_effort',
13
+ 'get_model_pricing', 'approx_pricing', 'is_deepseek_peak_hour', 'price_tier', 'tier_rate']
13
14
 
14
15
  # %% ../nbs/00_types.ipynb #b4d047fd
15
16
  import httpx2, base64, io
@@ -32,6 +33,12 @@ class Usage(BasicRepr):
32
33
  def __eq__(self, o): return type(o) is type(self) and self.__dict__ == o.__dict__
33
34
 
34
35
 
36
+ # %% ../nbs/00_types.ipynb #bae1c130
37
+ @patch
38
+ def __add__(self:Usage, o):
39
+ fields = 'prompt_tokens completion_tokens total_tokens cached_tokens cache_creation_tokens reasoning_tokens'.split()
40
+ return Usage(**{k: getattr(self, k)+getattr(o, k) for k in fields}, raw=o.raw)
41
+
35
42
  # %% ../nbs/00_types.ipynb #4901e693
36
43
  @patch(as_prop=True)
37
44
  def formatted(self:Completion): return ''
@@ -160,6 +167,9 @@ def model_prices_meta():
160
167
  if not p.exists(): p.write_text(httpx2.get(model_prices_url, follow_redirects=True).text)
161
168
  return loads(p.read_text())
162
169
 
170
+ # %% ../nbs/00_types.ipynb #019882a3
171
+ sample_img_url = "https://img.magnific.com/free-photo/mountain-range-body-water_53876-139760.jpg?semt=ais_hybrid&w=740&q=80"
172
+
163
173
  # %% ../nbs/00_types.ipynb #68e488d8
164
174
  def infer_api_name(model):
165
175
  "Infer api_name from model"
@@ -217,50 +227,45 @@ modern_llm = dict(supports_function_calling=True, supports_tool_choice=True, sup
217
227
  supports_reasoning=True, supports_response_schema=True, supports_system_messages=True)
218
228
 
219
229
  # %% ../nbs/00_types.ipynb #8261dcd0
220
- register_model_info('accounts/fireworks/models/qwen3p7-plus', vendor_name='fireworks_ai', **modern_llm, supports_vision=True,
221
- max_tokens=1000000, max_input_tokens=1000000, max_output_tokens=65536,
222
- input_cost_per_token=0.4e-6, cache_read_input_token_cost=0.08e-6, output_cost_per_token=1.6e-6)
223
-
224
- register_model_info('accounts/fireworks/models/qwen3p6-plus', vendor_name='fireworks_ai', **modern_llm, supports_vision=True,
225
- max_tokens=1000000, max_input_tokens=1000000, max_output_tokens=65536,
226
- input_cost_per_token=0.5e-6, cache_read_input_token_cost=0.1e-6, output_cost_per_token=3.0e-6)
227
-
228
- register_model_info('accounts/fireworks/models/glm-5p2', vendor_name='fireworks_ai', **modern_llm, supports_vision=False,
229
- max_tokens=1000000, max_input_tokens=1000000, max_output_tokens=65536,
230
- input_cost_per_token=1.4e-6, cache_read_input_token_cost=0.14e-6, output_cost_per_token=4.4e-6)
231
-
232
- register_model_info('accounts/fireworks/routers/glm-5p2-fast', vendor_name='fireworks_ai', **modern_llm, supports_vision=False,
233
- max_tokens=1000000, max_input_tokens=1000000, max_output_tokens=65536,
234
- input_cost_per_token=2.8e-6, cache_read_input_token_cost=0.28e-6, output_cost_per_token=8.8e-6)
235
-
236
- register_model_info('gemini-3.5-flash', vendor_name='gemini', base='gemini-3-flash-preview',
237
- input_cost_per_token=1.5e-6, output_cost_per_token=9e-6, output_cost_per_reasoning_token=9e-6, cache_read_input_token_cost=1.5e-7)
238
-
239
- register_model_info('gpt-5.4', vendor_name='openai', base='gpt-5.4', supports_web_search=True, mode=None)
240
- # Upstream metadata says 1,050,000 input tokens for gpt-5.4-mini, but OpenAI documents a 272k limit
241
- register_model_info('gpt-5.4-mini', vendor_name='openai', base='gpt-5.4-mini', supports_web_search=True, mode=None, max_input_tokens=272_000)
242
-
243
- for model in ('kimi-k2.5', 'kimi-k2.6' , 'kimi-k3'):
244
- register_model_info(model, vendor_name='moonshot', base=f'moonshot/{model}', base_vendor_name=None,
245
- supports_reasoning=True, supports_vision=True)
246
-
247
- register_model_info('gemini-3.1-flash-lite', vendor_name='gemini', base='gemini-3.1-flash-lite-preview')
248
- register_model_info('models/gemini-3.1-flash-lite', vendor_name='gemini', base='gemini-3.1-flash-lite-preview')
249
-
250
- register_model_info('accounts/fireworks/models/kimi-k2p5', vendor_name='fireworks_ai', base='accounts/fireworks/models/kimi-k2p5',
251
- supports_reasoning=True, supports_vision=True,
252
- input_cost_per_token=0.60e-6, cache_read_input_token_cost=0.10e-6, output_cost_per_token=3.0e-6)
253
- register_model_info('accounts/fireworks/models/kimi-k2p6', vendor_name='fireworks_ai', base='accounts/fireworks/models/kimi-k2p5',
254
- supports_reasoning=True, supports_vision=True,
255
- input_cost_per_token=0.95e-6, cache_read_input_token_cost=0.16e-6, output_cost_per_token=4.0e-6)
256
- register_model_info('kimi-k2.7-code', vendor_name='moonshot', base='kimi-k2.6',
257
- input_cost_per_token=0.95e-6, cache_read_input_token_cost=0.19e-6, output_cost_per_token=4.0e-6)
258
- register_model_info('kimi-k3', vendor_name='moonshot', base='kimi-k2.6', supports_reasoning=True, supports_vision=True,
259
- max_input_tokens=1_000_000, input_cost_per_token=3e-6, cache_read_input_token_cost=0.30e-6, output_cost_per_token=15.0e-6)
230
+ register_model_info('gpt-5.4', vendor_name='openai', base='gpt-5.4', supports_web_search=True)
231
+
232
+ register_model_info('accounts/fireworks/models/deepseek-v4-flash-vision-exp', vendor_name='fireworks_ai',
233
+ base='accounts/fireworks/models/deepseek-v4-flash-0731', supports_vision=True)
234
+ register_model_info('glm-5.3-flash', vendor_name='zai', base='glm-5.3-flash', reasoning_effort_levels=['low', 'high', 'max'])
235
+ register_model_info('accounts/fireworks/models/glm-5p3-flash', vendor_name='fireworks_ai', base='glm-5.3-flash', base_vendor_name='zai')
236
+
237
+ # %% ../nbs/00_types.ipynb #b6279e07
238
+ effort_codes = dict(n='none', l='low', m='medium', h='high', x='xhigh')
239
+
240
+ def effort_code(v):
241
+ "The effort code (`n l m h x`) for an effort name, code, or level 0-3"
242
+ if isinstance(v, int): return 'lmhx'[v]
243
+ return {**{v:k for k,v in effort_codes.items()}, 'max':'x'}.get(v, v)
244
+
245
+ def effort_levels(info):
246
+ "Ordered reasoning effort levels a model accepts, lowest first, from its metadata `info`; empty when it does not reason"
247
+ if not info.get('supports_reasoning'): return []
248
+ if lv := info.get('reasoning_effort_levels'): return list(lv)
249
+ lv = ['low', 'medium', 'high']
250
+ if info.get('supports_none_reasoning_effort'): lv.insert(0, 'none')
251
+ if info.get('supports_xhigh_reasoning_effort'): lv.append('xhigh')
252
+ if info.get('supports_max_reasoning_effort'): lv.append('max')
253
+ return lv
254
+
255
+ def resolve_effort(levels, effort):
256
+ "The level among ordered `levels` that `effort` (a code `n l m h x`, a name, or a level 0-3) selects; None when `n` has no `none` level"
257
+ code = effort_code(effort)
258
+ lv = [o for o in levels if o != 'none']
259
+ if code not in effort_codes: return effort
260
+ if not lv: return effort if isinstance(effort, int) else effort_codes[code]
261
+ if code == 'n': return 'none' if 'none' in levels else None
262
+ if code == 'x': return lv[-1]
263
+ return lv[min('lmh'.index(code), len(lv)-1)]
264
+
260
265
 
261
266
  # %% ../nbs/00_types.ipynb #948d55d0
262
267
  # The price map carries DeepSeek's peak rates, and `Completion.cost` doubles at peak, so register the off-peak rates
263
- deepseek_v4_common = dict(supports_native_structured_output=True, max_input_tokens=1048576)
268
+ deepseek_v4_common = dict(supports_native_structured_output=True)
264
269
  deepseek_v4_flash_prices = dict(input_cost_per_token=0.22e-6, input_cost_per_token_cache_hit=0.007e-6,
265
270
  cache_read_input_token_cost=0.007e-6, output_cost_per_token=0.66e-6)
266
271
 
@@ -291,12 +296,6 @@ register_model_info('muse-spark-1.1', vendor_name='meta_ai', **modern_llm, max_i
291
296
  supports_vision=True, supports_image_input=True, supports_video_input=True, supports_pdf_input=True, supports_web_search=True,
292
297
  search_context_cost_per_query=0.0025, input_cost_per_token=1.25e-6, output_cost_per_token=4.25e-6, cache_read_input_token_cost=0.15e-6)
293
298
 
294
- # %% ../nbs/00_types.ipynb #5a2b3430
295
- # z.ai list pricing (a 50% discount applies until 2026-09-09)
296
- register_model_info('glm-5.3-flash', vendor_name='zai', base='glm-5.1', **modern_llm, supports_vision=True, supports_image_input=True,
297
- max_input_tokens=1000000, max_output_tokens=128000, max_tokens=128000, input_cost_per_token=1.5e-07, cache_read_input_token_cost=3e-08,
298
- output_cost_per_token=5e-07)
299
-
300
299
  # %% ../nbs/00_types.ipynb #2c23d11e
301
300
  codex_pricing = dict(input_cost_per_token = 0.10/1_000_000, output_cost_per_token = 0.50/1_000_000,
302
301
  cache_creation_input_token_cost = 0.10/1_000_000, cache_read_input_token_cost = 0.10/1_000_000)
@@ -322,7 +321,6 @@ for model in (haik45, sonn45, sonn46, sonn5, opus46, opus48, opus5, fable5, fabl
322
321
 
323
322
  # %% ../nbs/00_types.ipynb #bb0c4c2a
324
323
  sol,terra,luna = gpt56s = 'gpt-5.6-sol gpt-5.6-terra gpt-5.6-luna'.split()
325
- for model in ['gpt-5.6']+gpt56s: register_model_info(model, 'openai', base=model, base_vendor_name='openai')
326
324
  # Codex serves only the suffixed names; bare `gpt-5.6` is rejected with a ChatGPT account.
327
325
  # Its window is smaller than the API's: 371,331 input tokens is accepted and 371,981 is not, on all three.
328
326
  for model in gpt56s:
@@ -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.46
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 *