python-fastllm 0.0.48__py3-none-any.whl → 0.0.50__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.
fastllm/__init__.py CHANGED
@@ -2,4 +2,4 @@
2
2
 
3
3
  - `fastllm.responses`: Provider-independent Responses resources and streaming over FastLLM"""
4
4
 
5
- __version__ = "0.0.48"
5
+ __version__ = "0.0.50"
fastllm/_modidx.py CHANGED
@@ -25,6 +25,7 @@ d = { 'settings': { 'branch': 'main',
25
25
  'fastllm.anthropic._sanid': ('anthropic.html#_sanid', 'fastllm/anthropic.py'),
26
26
  'fastllm.anthropic.acollect_stream': ('anthropic.html#acollect_stream', 'fastllm/anthropic.py'),
27
27
  'fastllm.anthropic.apply_cache_idxs': ('anthropic.html#apply_cache_idxs', 'fastllm/anthropic.py'),
28
+ 'fastllm.anthropic.collate_raw': ('anthropic.html#collate_raw', 'fastllm/anthropic.py'),
28
29
  'fastllm.anthropic.cost': ('anthropic.html#cost', 'fastllm/anthropic.py'),
29
30
  'fastllm.anthropic.delta_index_fn': ('anthropic.html#delta_index_fn', 'fastllm/anthropic.py'),
30
31
  'fastllm.anthropic.denorm_assistant': ('anthropic.html#denorm_assistant', 'fastllm/anthropic.py'),
@@ -49,7 +50,8 @@ d = { 'settings': { 'branch': 'main',
49
50
  'fastllm.anthropic.norm_tool_call': ('anthropic.html#norm_tool_call', 'fastllm/anthropic.py'),
50
51
  'fastllm.anthropic.norm_tool_calls': ('anthropic.html#norm_tool_calls', 'fastllm/anthropic.py'),
51
52
  'fastllm.anthropic.norm_tr_parts': ('anthropic.html#norm_tr_parts', 'fastllm/anthropic.py'),
52
- 'fastllm.anthropic.norm_usage': ('anthropic.html#norm_usage', 'fastllm/anthropic.py')},
53
+ 'fastllm.anthropic.norm_usage': ('anthropic.html#norm_usage', 'fastllm/anthropic.py'),
54
+ 'fastllm.anthropic.raw_msg': ('anthropic.html#raw_msg', 'fastllm/anthropic.py')},
53
55
  'fastllm.chat': { 'fastllm.chat.AsyncChat': ('chat.html#asyncchat', 'fastllm/chat.py'),
54
56
  'fastllm.chat.AsyncChat.__call__': ('chat.html#asyncchat.__call__', 'fastllm/chat.py'),
55
57
  'fastllm.chat.AsyncChat.__init__': ('chat.html#asyncchat.__init__', 'fastllm/chat.py'),
@@ -139,6 +141,7 @@ d = { 'settings': { 'branch': 'main',
139
141
  'fastllm.gemini': { 'fastllm.gemini._gem_filter_sch': ('gemini.html#_gem_filter_sch', 'fastllm/gemini.py'),
140
142
  'fastllm.gemini._gem_part_type': ('gemini.html#_gem_part_type', 'fastllm/gemini.py'),
141
143
  'fastllm.gemini.acollect_stream': ('gemini.html#acollect_stream', 'fastllm/gemini.py'),
144
+ 'fastllm.gemini.collate_raw': ('gemini.html#collate_raw', 'fastllm/gemini.py'),
142
145
  'fastllm.gemini.cost': ('gemini.html#cost', 'fastllm/gemini.py'),
143
146
  'fastllm.gemini.delta_index_fn': ('gemini.html#delta_index_fn', 'fastllm/gemini.py'),
144
147
  'fastllm.gemini.denorm_assistant': ('gemini.html#denorm_assistant', 'fastllm/gemini.py'),
@@ -163,8 +166,10 @@ d = { 'settings': { 'branch': 'main',
163
166
  'fastllm.gemini.norm_parts': ('gemini.html#norm_parts', 'fastllm/gemini.py'),
164
167
  'fastllm.gemini.norm_sse_event': ('gemini.html#norm_sse_event', 'fastllm/gemini.py'),
165
168
  'fastllm.gemini.norm_tool_calls': ('gemini.html#norm_tool_calls', 'fastllm/gemini.py'),
166
- 'fastllm.gemini.norm_usage': ('gemini.html#norm_usage', 'fastllm/gemini.py')},
169
+ 'fastllm.gemini.norm_usage': ('gemini.html#norm_usage', 'fastllm/gemini.py'),
170
+ 'fastllm.gemini.raw_msg': ('gemini.html#raw_msg', 'fastllm/gemini.py')},
167
171
  'fastllm.openai_chat': { 'fastllm.openai_chat.acollect_stream': ('oai_chat.html#acollect_stream', 'fastllm/openai_chat.py'),
172
+ 'fastllm.openai_chat.collate_raw': ('oai_chat.html#collate_raw', 'fastllm/openai_chat.py'),
168
173
  'fastllm.openai_chat.cost': ('oai_chat.html#cost', 'fastllm/openai_chat.py'),
169
174
  'fastllm.openai_chat.delta_index_fn': ('oai_chat.html#delta_index_fn', 'fastllm/openai_chat.py'),
170
175
  'fastllm.openai_chat.denorm_assistant': ('oai_chat.html#denorm_assistant', 'fastllm/openai_chat.py'),
@@ -189,14 +194,15 @@ d = { 'settings': { 'branch': 'main',
189
194
  'fastllm.openai_chat.norm_finish': ('oai_chat.html#norm_finish', 'fastllm/openai_chat.py'),
190
195
  'fastllm.openai_chat.norm_parts': ('oai_chat.html#norm_parts', 'fastllm/openai_chat.py'),
191
196
  'fastllm.openai_chat.norm_sse_event': ('oai_chat.html#norm_sse_event', 'fastllm/openai_chat.py'),
192
- 'fastllm.openai_chat.norm_tool_calls': ('oai_chat.html#norm_tool_calls', 'fastllm/openai_chat.py')},
197
+ 'fastllm.openai_chat.norm_tool_calls': ('oai_chat.html#norm_tool_calls', 'fastllm/openai_chat.py'),
198
+ 'fastllm.openai_chat.raw_msg': ('oai_chat.html#raw_msg', 'fastllm/openai_chat.py')},
193
199
  'fastllm.openai_responses': { 'fastllm.openai_responses.Completion.response_id': ( 'oai_responses.html#completion.response_id',
194
200
  'fastllm/openai_responses.py'),
195
- 'fastllm.openai_responses.Completion.response_id_reusable': ( 'oai_responses.html#completion.response_id_reusable',
196
- 'fastllm/openai_responses.py'),
197
201
  'fastllm.openai_responses._sanid': ('oai_responses.html#_sanid', 'fastllm/openai_responses.py'),
198
202
  'fastllm.openai_responses.acollect_stream': ( 'oai_responses.html#acollect_stream',
199
203
  'fastllm/openai_responses.py'),
204
+ 'fastllm.openai_responses.collate_raw': ( 'oai_responses.html#collate_raw',
205
+ 'fastllm/openai_responses.py'),
200
206
  'fastllm.openai_responses.cost': ('oai_responses.html#cost', 'fastllm/openai_responses.py'),
201
207
  'fastllm.openai_responses.delta_index_fn': ( 'oai_responses.html#delta_index_fn',
202
208
  'fastllm/openai_responses.py'),
@@ -245,7 +251,9 @@ d = { 'settings': { 'branch': 'main',
245
251
  'fastllm.openai_responses.norm_tool_calls': ( 'oai_responses.html#norm_tool_calls',
246
252
  'fastllm/openai_responses.py'),
247
253
  'fastllm.openai_responses.norm_usage': ( 'oai_responses.html#norm_usage',
248
- 'fastllm/openai_responses.py')},
254
+ 'fastllm/openai_responses.py'),
255
+ 'fastllm.openai_responses.raw_msg': ( 'oai_responses.html#raw_msg',
256
+ 'fastllm/openai_responses.py')},
249
257
  'fastllm.responses': { 'fastllm.responses.AsyncResponses': ('responses.html#asyncresponses', 'fastllm/responses.py'),
250
258
  'fastllm.responses.AsyncResponses.call': ('responses.html#asyncresponses.call', 'fastllm/responses.py'),
251
259
  'fastllm.responses.AsyncResponses.events': ( 'responses.html#asyncresponses.events',
@@ -338,7 +346,7 @@ d = { 'settings': { 'branch': 'main',
338
346
  'fastllm.types.Usage.__add__': ('types.html#usage.__add__', 'fastllm/types.py'),
339
347
  'fastllm.types.Usage.__eq__': ('types.html#usage.__eq__', 'fastllm/types.py'),
340
348
  'fastllm.types.Usage.__init__': ('types.html#usage.__init__', 'fastllm/types.py'),
341
- 'fastllm.types._rm_ctx_tiers': ('types.html#_rm_ctx_tiers', 'fastllm/types.py'),
349
+ 'fastllm.types._flat_rates': ('types.html#_flat_rates', 'fastllm/types.py'),
342
350
  'fastllm.types.approx_pricing': ('types.html#approx_pricing', 'fastllm/types.py'),
343
351
  'fastllm.types.effort_code': ('types.html#effort_code', 'fastllm/types.py'),
344
352
  'fastllm.types.effort_levels': ('types.html#effort_levels', 'fastllm/types.py'),
fastllm/acomplete.py CHANGED
@@ -67,10 +67,10 @@ def _codex_mtime_policy():
67
67
 
68
68
  # %% ../nbs/06_acomplete.ipynb #79075d95
69
69
  @flexicache(_codex_mtime_policy())
70
- 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,
71
71
  timeout=httpx2.Timeout(connect=30, read=300, write=30, pool=10)):
72
72
  err_msg = f"please pass a valid one vendor: {', '.join(list(vendor_mapping))} or pass `api_name`,`base_url` and `api_key`"
73
- 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)):
74
74
  v, m = split_vendor(model)
75
75
  if v in vendor_mapping: vendor_name, model = v, m
76
76
  if vendor_name:
@@ -78,17 +78,17 @@ def mk_client(model=None, vendor_name=None, api_name=None, api_key=None, base_ur
78
78
  try:
79
79
  api_name, base_url, env_api_nm, *auth_json = vendor_mapping[vendor_name]
80
80
  base_url = override_base_url or base_url
81
- 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):
82
82
  fn,keys = auth_json[0] # pyright: ignore[reportAssignmentType]
83
83
  auth_fn = Path(fn).expanduser()
84
84
  if auth_fn.exists(): api_key = nested_idx(json.loads(auth_fn.read_text()), *keys)
85
- 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)
86
86
  except KeyError: raise ValueError(f"Unknown vendor '{vendor_name}', {err_msg}")
87
- 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')
88
88
  elif (api_name:=infer_api_name(model)): base_url, vendor_name = vendor_mapping[api_name][1], api_name
89
89
  else: raise ValueError(f"Model {model} can't be auto resolved, {err_msg}")
90
90
  api = api_registry[api_name]
91
- hdrs = merge(api.get_hdrs(api_key), ifnone(xtra_hdrs, {}))
91
+ hdrs = merge(api.get_hdrs(api_key, oauth_token), ifnone(xtra_hdrs, {}))
92
92
  cli = AsyncHttpCli(base_url, base_headers=hdrs, timeout=timeout)
93
93
  return cli, api_name, vendor_name
94
94
 
@@ -155,18 +155,19 @@ async def _retry_stream(mk_gen, retries=2, retry_delay=0.5):
155
155
 
156
156
  # %% ../nbs/06_acomplete.ipynb #2379ec94
157
157
  @delegates(payload_kwargs)
158
- 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,
159
159
  stream=False, previous_response_id=None, stop_callables=None, retries=2, retry_delay=0.5, **kwargs):
160
160
  "Unified completion across different APIs."
161
- 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)):
162
163
  v, m = split_vendor(model)
163
164
  if v in vendor_mapping: vendor_name, model = v, m
164
165
  elif v: api_name, model = v, m # a registered transport api (e.g. claude_code): not an HTTP vendor
165
166
  own = api_name in api_registry and not hasattr(api_registry[api_name], 'endpoint') if api_name else False
166
167
  if own:
167
168
  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
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)
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)
170
171
  api = api_registry[api_name]
171
172
  if previous_response_id is not None:
172
173
  if not getattr(api, 'supports_previous_response_id', False):
fastllm/anthropic.py CHANGED
@@ -2,20 +2,20 @@
2
2
 
3
3
  # %% auto #0
4
4
  __all__ = ['ant_tc_types', 'norm_tool_call', 'norm_tool_calls', 'norm_usage', 'finalize_usage', 'norm_finish', 'norm_parts',
5
- 'norm_tr_parts', 'norm_sse_event', 'delta_index_fn', 'acollect_stream', 'denorm_tool_use',
6
- 'denorm_assistant', 'denorm_tool', 'denorm_msgs', 'denorm_tool_schs', 'denorm_tool_choice',
7
- 'denorm_reasoning', 'denorm_web_search', 'denorm_system', 'denorm_user', 'denorm_image', 'denorm_file',
8
- 'denorm_tool_result', 'apply_cache_idxs', 'mk_payload', 'get_hdrs', 'cost']
5
+ 'raw_msg', 'norm_tr_parts', 'norm_sse_event', 'delta_index_fn', 'collate_raw', 'acollect_stream',
6
+ 'denorm_tool_use', 'denorm_assistant', 'denorm_tool', 'denorm_msgs', 'denorm_tool_schs',
7
+ 'denorm_tool_choice', 'denorm_reasoning', 'denorm_web_search', 'denorm_system', 'denorm_user',
8
+ 'denorm_image', 'denorm_file', 'denorm_tool_result', 'apply_cache_idxs', 'mk_payload', 'get_hdrs', 'cost']
9
9
 
10
10
  # %% ../nbs/04_anthropic.ipynb #02afd3d7
11
- import json
11
+ import json, copy
12
12
  from collections import Counter
13
13
  from fastcore.utils import *
14
14
  from fastcore.meta import *
15
15
  from fasttransport.errors import api_error_from_event
16
16
 
17
17
  from .types import *
18
- from aidialog.msg_parts import (Part, PartType, mk_part, Msg, Text, Thinking, ToolUse, ToolResult, tool_text, ServerToolResult,
18
+ from aidialog.msg_parts import (Part, PartType, mk_part, Msg, Text, Thinking, ToolUse, ToolResult, tool_text,
19
19
  InputImage, InputAudio, InputVideo, InputFile, data_url)
20
20
  from .streaming import *
21
21
  from .streaming import mk_acollect_stream
@@ -47,11 +47,11 @@ def norm_usage(resp):
47
47
  cached_tokens=cached, cache_creation_tokens=cache_creation, reasoning_tokens=0, raw=usg)
48
48
 
49
49
  def finalize_usage(usg, parts):
50
- "Adjust usage using finalized Anthropic content parts."
50
+ "Split reasoning tokens out of `output_tokens`: the reported `thinking_tokens`, else an estimate from the thinking text."
51
51
  if not usg: return usg
52
52
  rc = '\n'.join(p.text or '' for p in parts if isinstance(p, Thinking))
53
53
  ct = int(usg.raw.get('output_tokens', usg.completion_tokens) or 0)
54
- rt = min(int(len(rc.split())*1.5), ct) if rc else 0
54
+ rt = int(nested_idx(usg.raw, 'output_tokens_details', 'thinking_tokens') or 0) or (min(int(len(rc.split())*1.5), ct) if rc else 0)
55
55
  return Usage(prompt_tokens=usg.prompt_tokens, completion_tokens=ct-rt, total_tokens=usg.prompt_tokens+ct,
56
56
  cached_tokens=usg.cached_tokens, cache_creation_tokens=usg.cache_creation_tokens, reasoning_tokens=rt, raw=usg.raw)
57
57
 
@@ -67,21 +67,24 @@ def norm_finish(resp, tcs=None):
67
67
  def _ant_part_type(typ):
68
68
  "Map Anthropic content block type to canonical PartType."
69
69
  ant_parts_mapping = dict(text=PartType.text, thinking=PartType.thinking, redacted_thinking=PartType.thinking, tool_use=PartType.tool_use, server_tool_use=PartType.tool_use, mcp_tool_use=PartType.tool_use)
70
- if typ in ant_parts_mapping: return ant_parts_mapping[typ]
71
- if typ.endswith('_tool_result'): return PartType.server_tool_result
72
- return typ
70
+ return ant_parts_mapping.get(typ, typ)
73
71
 
74
72
  def norm_parts(resp):
73
+ "Canonical parts of a response; a server tool's result block lives only in the raw message"
75
74
  parts = []
76
75
  for b in resp.get("content", []):
77
76
  typ = _ant_part_type(b.get("type", "text"))
78
77
  if typ == PartType.thinking: parts.append(Thinking(b.get("thinking", ""), raw=b))
79
78
  elif typ == PartType.tool_use:
80
79
  if tc := norm_tool_call(b): parts.append(tc)
81
- elif typ == PartType.server_tool_result: parts.append(ServerToolResult(b.get("text", ""), raw=b))
82
- else: parts.append(Text(b.get("text", ""), raw=b))
80
+ elif typ == PartType.text: parts.append(Text(b.get("text", ""), raw=b))
83
81
  return parts
84
82
 
83
+ # %% ../nbs/04_anthropic.ipynb #f24e52db
84
+ def raw_msg(resp):
85
+ "The assistant message as the API sent it"
86
+ return dict(role='assistant', content=resp['content'])
87
+
85
88
  # %% ../nbs/04_anthropic.ipynb #5d1440b7
86
89
  def norm_tr_parts(content):
87
90
  "Normalize an Anthropic `tool_result` content str or block list to canonical `Part`s"
@@ -103,7 +106,6 @@ def norm_sse_event(ev, **kwargs):
103
106
  text, thinking, tcs, citations = None, None, [], None
104
107
  if typ == "content_block_start":
105
108
  cb = ev.get("content_block", {})
106
- if cb.get("type", "").endswith("_tool_result"): return Delta(server_tool_result=cb, raw=ev, **kwargs)
107
109
  if tc := norm_tool_call(cb):
108
110
  if not tc.arguments: tc.arguments = {'_delta': ''}
109
111
  tcs = [tc]
@@ -127,6 +129,23 @@ def delta_index_fn(d, typ, last_typ, last_idx):
127
129
  'Returns accumulation index for current delta and updated last idx'
128
130
  return nested_idx(d, 'raw', 'index'), None
129
131
 
132
+ # %% ../nbs/04_anthropic.ipynb #de900e34
133
+ def collate_raw(deltas):
134
+ "The assistant message rebuilt from a stream's events"
135
+ blocks = {}
136
+ for ev in L(deltas).attrgot('raw'):
137
+ typ,i = ev.get('type'),ev.get('index')
138
+ if typ == 'content_block_start': blocks[i] = copy.deepcopy(ev['content_block'])
139
+ elif typ == 'content_block_delta':
140
+ d,b = ev['delta'],blocks[i]
141
+ if d['type'] == 'text_delta': b['text'] += d['text']
142
+ elif d['type'] == 'thinking_delta': b['thinking'] += d['thinking']
143
+ elif d['type'] == 'signature_delta': b['signature'] = d['signature']
144
+ elif d['type'] == 'input_json_delta': b['_json'] = b.get('_json', '') + d['partial_json']
145
+ elif d['type'] == 'citations_delta': b['citations'] = (b.get('citations') or []) + [d['citation']]
146
+ elif typ == 'content_block_stop' and '_json' in blocks[i]: blocks[i]['input'] = json.loads(blocks[i].pop('_json') or '{}')
147
+ return dict(role='assistant', content=list(blocks.values()))
148
+
130
149
  # %% ../nbs/04_anthropic.ipynb #c994adb1
131
150
  @delegates(mk_acollect_stream, but=['index_fn', 'api_name'])
132
151
  async def acollect_stream(resp, **kwargs):
@@ -145,44 +164,29 @@ def _sanid(id_str): return re.sub(r'[^a-zA-Z0-9_-]', '_', id_str or '')
145
164
  # %% ../nbs/04_anthropic.ipynb #6ec772cb
146
165
  def denorm_tool_use(p:ToolUse):
147
166
  "Convert canonical tool_use Part to Anthropic tool_use content block."
148
- block = dict(type='tool_use', id=_sanid(p.id or ''), name=p.name or '', input=p.arguments or {})
149
- if caller := (p.raw or {}).get('caller'): block['caller'] = caller
150
- return _ant_cc(block, p)
167
+ return _ant_cc(dict(type='tool_use', id=_sanid(p.id or ''), name=p.name or '', input=p.arguments or {}), p)
151
168
 
152
169
  def denorm_assistant(m:Msg):
153
- "Convert canonical assistant Msg to Anthropic assistant message + synthetic tool results for non-Anthropic server tools."
154
- blocks, srv_results, srv_call_id = [], [], None
155
- for p in m.content:
156
- if isinstance(p, (Thinking, Text)):
157
- if srv_call_id:
158
- srv_results.append(dict(type='tool_result', tool_use_id=srv_call_id, content=p.text or ''))
159
- srv_call_id = None
160
- elif isinstance(p, Thinking) and (sig:=(p.raw or {}).get('signature','')):
161
- blocks.append(dict(type='thinking', thinking=p.text or '', signature=sig))
162
- else: blocks.append(_ant_cc(dict(type='text', text=p.text or ''), p))
163
- elif isinstance(p, ToolUse):
164
- if p.server and (p.id or '').startswith('srvtoolu_'):
165
- blocks.append(dict(type='server_tool_use', id=p.id, name=p.name or '', input=p.arguments or {}))
166
- else:
167
- blocks.append(denorm_tool_use(p))
168
- if p.server: srv_call_id = p.id or ''
169
- elif isinstance(p, ServerToolResult): blocks.append(p.raw or dict(type='server_tool_result'))
170
- res = [dict(role='assistant', content=blocks)]
171
- if srv_results: res.append(dict(role='user', content=srv_results))
172
- return res
170
+ "The message as the API sent it, else its text and tool calls; a cache mark on the last part is copied onto the last raw block"
171
+ if m.raw:
172
+ cc = [p.cache_control for p in m.content if p.cache_control]
173
+ if not cc: return m.raw
174
+ return m.raw | dict(content=m.raw['content'][:-1] + [m.raw['content'][-1] | dict(cache_control=cc[-1])])
175
+ blocks = [_ant_cc(dict(type='text', text=p.text or ''), p) if isinstance(p, Text) else denorm_tool_use(p) for p in m.content if isinstance(p, (Text, ToolUse))]
176
+ return dict(role='assistant', content=blocks)
173
177
 
174
178
  def denorm_tool(m:Msg):
175
179
  "Convert canonical tool Msg to Anthropic user message with tool_result blocks."
176
180
  blocks = [denorm_tool_result(p) for p in m.content if isinstance(p, ToolResult)]
177
- return [dict(role='user', content=blocks)]
181
+ return dict(role='user', content=blocks)
178
182
 
179
183
  def denorm_msgs(msgs:list[Msg]):
180
184
  "Convert list of canonical Msgs to Anthropic messages."
181
185
  res = []
182
186
  for m in msgs:
183
187
  if m.role == 'user': res.append(denorm_user(m))
184
- elif m.role == 'assistant': res.extend(denorm_assistant(m))
185
- elif m.role == 'tool': res.extend(denorm_tool(m))
188
+ elif m.role == 'assistant': res.append(denorm_assistant(m))
189
+ elif m.role == 'tool': res.append(denorm_tool(m))
186
190
  return res
187
191
 
188
192
  # %% ../nbs/04_anthropic.ipynb #f762a6f2
@@ -322,8 +326,12 @@ def mk_payload(msgs, model, **kwargs):
322
326
  return payload
323
327
 
324
328
  # %% ../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"}
329
+ def get_hdrs(api_key=None, oauth_token=None):
330
+ "An OAuth token travels as a bearer under Anthropic's OAuth beta; an API key as `x-api-key`"
331
+ hdrs = {"anthropic-version": "2023-06-01"}
332
+ if oauth_token: return hdrs | {"Authorization": f"Bearer {oauth_token}", "anthropic-beta": "oauth-2025-04-20"}
333
+ return hdrs | {"x-api-key": get_api_key(api_key, 'ANTHROPIC_API_KEY')}
334
+
327
335
 
328
336
  # %% ../nbs/04_anthropic.ipynb #0d03643a
329
337
  def cost(usage, m):
@@ -342,5 +350,6 @@ def cost(usage, m):
342
350
 
343
351
  # %% ../nbs/04_anthropic.ipynb #f7c0b989
344
352
  api_registry.register('anthropic', norm_tool_calls=norm_tool_calls, norm_parts=norm_parts, norm_finish=norm_finish, norm_usage=norm_usage,
345
- finalize_usage=finalize_usage, acollect_stream=acollect_stream, mk_payload=mk_payload, cost=cost, get_hdrs=get_hdrs,
353
+ finalize_usage=finalize_usage, raw_msg=raw_msg, collate_raw=collate_raw, acollect_stream=acollect_stream, mk_payload=mk_payload,
354
+ cost=cost, get_hdrs=get_hdrs,
346
355
  endpoint='/v1/messages')
fastllm/chat.py CHANGED
@@ -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
fastllm/gemini.py CHANGED
@@ -1,11 +1,11 @@
1
1
  # AUTOGENERATED! DO NOT EDIT! File to edit: ../nbs/05_gemini.ipynb.
2
2
 
3
3
  # %% auto #0
4
- __all__ = ['api_ns', 'endpoint', 'norm_tool_calls', 'norm_usage', 'norm_finish', 'norm_parts', 'norm_sse_event', 'delta_index_fn',
5
- 'acollect_stream', 'denorm_tool_use', 'denorm_assistant', 'denorm_tool', 'denorm_msgs', 'denorm_tool_schs',
6
- 'denorm_tool_choice', 'denorm_reasoning', 'denorm_web_search', 'denorm_system', 'denorm_user',
7
- 'denorm_image', 'denorm_audio', 'denorm_video', 'denorm_file', 'denorm_tool_result', 'mk_payload',
8
- 'get_hdrs', 'cost']
4
+ __all__ = ['api_ns', 'endpoint', 'norm_tool_calls', 'norm_usage', 'norm_finish', 'norm_parts', 'raw_msg', 'norm_sse_event',
5
+ 'delta_index_fn', 'collate_raw', 'acollect_stream', 'denorm_tool_use', 'denorm_assistant', 'denorm_tool',
6
+ 'denorm_msgs', 'denorm_tool_schs', 'denorm_tool_choice', 'denorm_reasoning', 'denorm_web_search',
7
+ 'denorm_system', 'denorm_user', 'denorm_image', 'denorm_audio', 'denorm_video', 'denorm_file',
8
+ 'denorm_tool_result', 'mk_payload', 'get_hdrs', 'cost']
9
9
 
10
10
  # %% ../nbs/05_gemini.ipynb #02afd3d7
11
11
  import json
@@ -27,12 +27,14 @@ def endpoint(model, stream):
27
27
 
28
28
  # %% ../nbs/05_gemini.ipynb #f1eb32f3
29
29
  def norm_tool_calls(resp):
30
- "Extract Gemini functionCall parts as normalized tool calls."
30
+ "Tool calls of a response: `functionCall` parts, and a `toolCall` part for a search the API ran itself"
31
31
  out = []
32
32
  for i,p in enumerate(nested_idx(resp, 'candidates', 0, 'content', 'parts') or []):
33
- if not (fc:=p.get("functionCall")): continue
34
- raw = {k:v for k,v in p.items() if k != "functionCall"}
35
- out.append(ToolUse(id=fc.get("id", f"call_{i}"), name=fc.get("name", ""), arguments=fc.get("args") or {}, raw=raw))
33
+ if fc := p.get("functionCall"):
34
+ raw = {k:v for k,v in p.items() if k != "functionCall"}
35
+ out.append(ToolUse(id=fc.get("id", f"call_{i}"), name=fc.get("name", ""), arguments=fc.get("args") or {}, raw=raw))
36
+ elif tc := p.get("toolCall"):
37
+ out.append(ToolUse(id=tc.get("id", f"call_{i}"), name=tc.get("toolType", "").lower(), arguments=tc.get("args") or {}, server=True))
36
38
  return out
37
39
 
38
40
  # %% ../nbs/05_gemini.ipynb #90504163
@@ -48,7 +50,7 @@ def norm_usage(resp):
48
50
  cand = nested_idx(resp, 'candidates', 0) or {}
49
51
  stu = {}
50
52
  if any("executableCode" in p for p in parts): stu["code_execution"] = sum(1 for p in parts if "executableCode" in p)
51
- if "groundingMetadata" in cand: stu["google_search"] = 1
53
+ if n := ("groundingMetadata" in cand) + sum(1 for p in parts if 'toolCall' in p): stu["google_search"] = n
52
54
  if stu: usg['server_tool_use'] = stu
53
55
  return Usage(prompt_tokens=pt, completion_tokens=ct, total_tokens=tt,
54
56
  cached_tokens=cached, reasoning_tokens=reasoning, raw=usg)
@@ -76,6 +78,7 @@ def norm_parts(resp):
76
78
  tc_map = {tc.id: tc for tc in norm_tool_calls(resp)}
77
79
  parts = []
78
80
  for p in nested_idx(c0, 'content', 'parts') or []:
81
+ if 'toolResponse' in p: continue
79
82
  typ = _gem_part_type(p)
80
83
  if typ == PartType.tool_use:
81
84
  fc = p.get('functionCall') or p.get('toolCall') or {}
@@ -88,6 +91,11 @@ def norm_parts(resp):
88
91
  if isinstance(p, Text): p.citations = citations
89
92
  return parts
90
93
 
94
+ # %% ../nbs/05_gemini.ipynb #3acc9538
95
+ def raw_msg(resp):
96
+ "The model content as the API sent it"
97
+ return nested_idx(resp, 'candidates', 0, 'content')
98
+
91
99
  # %% ../nbs/05_gemini.ipynb #9a5024ee
92
100
  def norm_sse_event(ev, **kwargs):
93
101
  "Normalize Gemini stream event into Delta."
@@ -107,6 +115,19 @@ def delta_index_fn(d, typ, last_typ, last_idx):
107
115
  if not (last_typ or last_idx): return 0,0
108
116
  return last_idx + 1, last_idx + 1
109
117
 
118
+ # %% ../nbs/05_gemini.ipynb #33506d56
119
+ def collate_raw(deltas):
120
+ "The model content rebuilt from a stream's chunks"
121
+ parts = []
122
+ for ev in L(deltas).attrgot('raw'):
123
+ for p in nested_idx(ev, 'candidates', 0, 'content', 'parts') or []:
124
+ last = parts[-1] if parts else None
125
+ if 'text' in p and last and 'text' in last and last.get('thought') == p.get('thought') and 'thoughtSignature' not in last:
126
+ last['text'] += p['text']
127
+ if 'thoughtSignature' in p: last['thoughtSignature'] = p['thoughtSignature']
128
+ else: parts.append(dict(p))
129
+ return dict(role='model', parts=parts)
130
+
110
131
  # %% ../nbs/05_gemini.ipynb #328af4d5
111
132
  @delegates(mk_acollect_stream, but=['index_fn', 'api_name'])
112
133
  async def acollect_stream(resp, **kwargs):
@@ -118,17 +139,12 @@ def denorm_tool_use(p:ToolUse):
118
139
  "Convert canonical tool_use Part to Gemini functionCall part."
119
140
  fc = dict(name=p.name or '', args=p.arguments or {})
120
141
  if p.id: fc['id'] = p.id
121
- part = dict(functionCall=fc)
122
- part['thoughtSignature'] = (p.raw or {}).get('thoughtSignature', 'skip_thought_signature_validator')
123
- return part
142
+ return dict(functionCall=fc)
124
143
 
125
144
  def denorm_assistant(m:Msg):
126
- "Convert canonical assistant Msg to Gemini model content."
127
- parts = []
128
- for p in m.content:
129
- if isinstance(p, Thinking): parts.append(dict(text=p.text or '', thought=True))
130
- elif isinstance(p, ToolUse): parts.append(denorm_tool_use(p))
131
- elif isinstance(p, Text): parts.append(dict(text=p.text or ''))
145
+ "The content as the API sent it, else its text and tool calls"
146
+ if m.raw: return m.raw
147
+ parts = [dict(text=p.text or '') if isinstance(p, Text) else denorm_tool_use(p) for p in m.content if isinstance(p, (Text, ToolUse))]
132
148
  return dict(role='model', parts=parts)
133
149
 
134
150
  def denorm_tool(m:Msg):
@@ -267,9 +283,12 @@ def mk_payload(msgs, model, **kwargs):
267
283
  return payload
268
284
 
269
285
  # %% ../nbs/05_gemini.ipynb #60d52c1f
270
- def get_hdrs(api_key=None):
286
+ def get_hdrs(api_key=None, oauth_token=None):
287
+ "Bearer auth for an OAuth token; otherwise the API key header"
288
+ if oauth_token: return {"Authorization": f"Bearer {oauth_token}"}
271
289
  return {"x-goog-api-key": get_api_key(api_key, 'GEMINI_API_KEY')}
272
290
 
291
+
273
292
  # %% ../nbs/05_gemini.ipynb #4ee3891f
274
293
  def cost(usage, m):
275
294
  raw = usage.raw
@@ -298,6 +317,8 @@ api_ns = dict(norm_tool_calls=norm_tool_calls,
298
317
  norm_finish=norm_finish,
299
318
  norm_usage=norm_usage,
300
319
  acollect_stream=acollect_stream,
320
+ raw_msg=raw_msg,
321
+ collate_raw=collate_raw,
301
322
  mk_payload=mk_payload,
302
323
  cost=cost,
303
324
  get_hdrs=get_hdrs,
fastllm/openai_chat.py CHANGED
@@ -1,8 +1,8 @@
1
1
  # AUTOGENERATED! DO NOT EDIT! File to edit: ../nbs/03_oai_chat.ipynb.
2
2
 
3
3
  # %% auto #0
4
- __all__ = ['api_ns', 'norm_tool_calls', 'norm_finish', 'norm_parts', 'norm_sse_event', 'delta_index_fn', 'acollect_stream',
5
- 'denorm_tool_use', 'denorm_assistant', 'denorm_tool', 'denorm_msgs', 'denorm_tool_schs',
4
+ __all__ = ['api_ns', 'norm_tool_calls', 'norm_finish', 'norm_parts', 'raw_msg', 'norm_sse_event', 'delta_index_fn', 'collate_raw',
5
+ 'acollect_stream', 'denorm_tool_use', 'denorm_assistant', 'denorm_tool', 'denorm_msgs', 'denorm_tool_schs',
6
6
  'denorm_tool_choice', 'denorm_reasoning', 'denorm_web_search', 'denorm_system', 'denorm_user',
7
7
  'denorm_image', 'denorm_audio', 'denorm_file', 'denorm_tool_result', 'mk_payload', 'get_hdrs', 'cost',
8
8
  'fix_payload']
@@ -50,6 +50,11 @@ def norm_parts(resp):
50
50
  if ref := msg.get('refusal'): parts.append(Refusal(ref))
51
51
  return parts + norm_tool_calls(resp)
52
52
 
53
+ # %% ../nbs/03_oai_chat.ipynb #a30e4324
54
+ def raw_msg(resp):
55
+ "The assistant message as the API sent it"
56
+ return nested_idx(resp, 'choices', 0, 'message')
57
+
53
58
  # %% ../nbs/03_oai_chat.ipynb #9baac40e
54
59
  def norm_sse_event(ev, **kwargs):
55
60
  "Normalize a chat completion stream event."
@@ -73,6 +78,23 @@ def delta_index_fn(d, typ, last_typ, last_idx):
73
78
  if typ == last_typ: return last_idx, last_idx
74
79
  return last_idx + 1, last_idx + 1
75
80
 
81
+ # %% ../nbs/03_oai_chat.ipynb #b7a19b8d
82
+ def collate_raw(deltas):
83
+ "The assistant message rebuilt from a stream's chunks"
84
+ msg, tcs = dict(role='assistant', content=None), {}
85
+ for ev in L(deltas).attrgot('raw'):
86
+ d = nested_idx(ev, 'choices', 0, 'delta') or {}
87
+ for k in ('content', 'reasoning_content', 'refusal'):
88
+ if d.get(k): msg[k] = (msg.get(k) or '') + d[k]
89
+ for tc in d.get('tool_calls') or []:
90
+ t = tcs.setdefault(tc['index'], dict(id=tc.get('id'), type='function', function=dict(name='', arguments='')))
91
+ if tc.get('id'): t['id'] = tc['id']
92
+ fn = tc.get('function') or {}
93
+ t['function']['name'] += fn.get('name') or ''
94
+ t['function']['arguments'] += fn.get('arguments') or ''
95
+ if tcs: msg['tool_calls'] = [tcs[i] for i in sorted(tcs)]
96
+ return msg
97
+
76
98
  # %% ../nbs/03_oai_chat.ipynb #e7f8965b
77
99
  @delegates(mk_acollect_stream, but=['index_fn', 'api_name'])
78
100
  async def acollect_stream(resp, **kwargs):
@@ -85,21 +107,12 @@ def denorm_tool_use(p:ToolUse):
85
107
  return dict(id=p.id, type='function', function=dict(name=p.name, arguments=json.dumps(p.arguments)))
86
108
 
87
109
  def denorm_assistant(m:Msg):
88
- "Convert canonical assistant Msg to OpenAI Chat assistant message + synthetic tool responses for server tools."
89
- tcs, srv_responses, texts = [], [], []
90
- for p in m.content:
91
- if isinstance(p, ToolUse):
92
- tcs.append(denorm_tool_use(p))
93
- if p.server:
94
- srv_txt = f"[Server tool `{p.name}` executed successfully, results are generated]"
95
- srv_responses.append(dict(role='tool', tool_call_id=p.id, content=srv_txt))
96
- elif isinstance(p, Text): texts.append(p)
97
- msg = dict(role='assistant')
110
+ "The message as the API sent it, else its text and tool calls"
111
+ if m.raw: return m.raw
112
+ msg, texts = dict(role='assistant'), [p for p in m.content if isinstance(p, Text)]
98
113
  if texts: msg['content'] = texts[0].text if len(texts)==1 else [dict(type='text', text=p.text or '') for p in texts]
99
- if tcs: msg['tool_calls'] = tcs
100
- thinking = [p for p in m.content if isinstance(p, Thinking)]
101
- if thinking: msg['reasoning_content'] = ''.join(p.text or '' for p in thinking)
102
- return [msg] + srv_responses
114
+ if tcs := [denorm_tool_use(p) for p in m.content if isinstance(p, ToolUse)]: msg['tool_calls'] = tcs
115
+ return msg
103
116
 
104
117
  def denorm_tool(m:Msg):
105
118
  "Convert canonical tool Msg to list of OpenAI Chat tool messages."
@@ -110,7 +123,7 @@ def denorm_msgs(msgs:list[Msg]):
110
123
  res = []
111
124
  for m in msgs:
112
125
  if m.role == 'user': res.append(denorm_user(m))
113
- elif m.role == 'assistant': res.extend(denorm_assistant(m))
126
+ elif m.role == 'assistant': res.append(denorm_assistant(m))
114
127
  elif m.role == 'tool': res.extend(denorm_tool(m))
115
128
  return res
116
129
 
@@ -198,7 +211,10 @@ def mk_payload(msgs, model, **kwargs):
198
211
  return payload
199
212
 
200
213
  # %% ../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')}"}
214
+ def get_hdrs(api_key=None, oauth_token=None):
215
+ "Bearer auth: an OAuth access token, or else an API key from the argument or the environment"
216
+ return {"Authorization": f"Bearer {oauth_token or get_api_key(api_key, 'OPENAI_API_KEY')}"}
217
+
202
218
 
203
219
  # %% ../nbs/03_oai_chat.ipynb #f89e2bf6
204
220
  def cost(usage, m):
@@ -223,6 +239,6 @@ def fix_payload(payload, model, vendor_name):
223
239
 
224
240
  # %% ../nbs/03_oai_chat.ipynb #e2b0908e
225
241
  api_ns = dict(norm_tool_calls=norm_tool_calls, norm_parts=norm_parts, norm_finish=norm_finish, norm_usage=norm_usage,
226
- acollect_stream=acollect_stream, mk_payload=mk_payload, cost=cost, get_hdrs=get_hdrs, fix_payload=fix_payload,
242
+ raw_msg=raw_msg, collate_raw=collate_raw, acollect_stream=acollect_stream, mk_payload=mk_payload, cost=cost, get_hdrs=get_hdrs, fix_payload=fix_payload,
227
243
  endpoint='/chat/completions')
228
244
  api_registry.register('openai_chat', **api_ns)
@@ -1,11 +1,11 @@
1
1
  # AUTOGENERATED! DO NOT EDIT! File to edit: ../nbs/02_oai_responses.ipynb.
2
2
 
3
3
  # %% auto #0
4
- __all__ = ['api_ns', 'norm_tool_call', 'norm_tool_calls', 'norm_usage', 'norm_finish', 'norm_parts', 'norm_sse_event',
5
- 'delta_index_fn', 'acollect_stream', 'denorm_tool_use', 'denorm_tool', 'denorm_assistant', 'denorm_msgs',
6
- 'denorm_tool_schs', 'denorm_tool_choice', 'denorm_reasoning', 'denorm_web_search', 'denorm_system',
7
- 'denorm_user', 'denorm_image', 'denorm_video', 'denorm_file', 'denorm_tool_result', 'mk_payload', 'get_hdrs',
8
- 'cost', 'fix_payload']
4
+ __all__ = ['api_ns', 'norm_tool_call', 'norm_tool_calls', 'norm_usage', 'norm_finish', 'norm_parts', 'raw_msg', 'norm_sse_event',
5
+ 'delta_index_fn', 'collate_raw', 'acollect_stream', 'denorm_tool_use', 'denorm_tool', 'denorm_assistant',
6
+ 'denorm_msgs', 'denorm_tool_schs', 'denorm_tool_choice', 'denorm_reasoning', 'denorm_web_search',
7
+ 'denorm_system', 'denorm_user', 'denorm_image', 'denorm_video', 'denorm_file', 'denorm_tool_result',
8
+ 'mk_payload', 'get_hdrs', 'cost', 'fix_payload']
9
9
 
10
10
  # %% ../nbs/02_oai_responses.ipynb #591f55b5
11
11
  import json
@@ -78,6 +78,11 @@ def norm_parts(resp):
78
78
  elif typ == "function_call" or typ.endswith("_call"): parts.append(norm_tool_call(item))
79
79
  return parts
80
80
 
81
+ # %% ../nbs/02_oai_responses.ipynb #2aaab962
82
+ def raw_msg(resp):
83
+ "The output items as the API sent them"
84
+ return resp['output']
85
+
81
86
  # %% ../nbs/02_oai_responses.ipynb #ef2d19f3
82
87
  @patch(as_prop=True)
83
88
  def response_id(self:Completion):
@@ -89,11 +94,6 @@ def response_id(self:Completion):
89
94
  for d in reversed(self.raw.get('deltas', [])):
90
95
  if rid := nested_idx(d.raw, 'response', 'id'): return rid
91
96
 
92
- @patch(as_prop=True)
93
- def response_id_reusable(self:Completion):
94
- "Can the provider continuation identifier be consumed more than once?"
95
- return self.raw.get('response_id_reusable', True)
96
-
97
97
  # %% ../nbs/02_oai_responses.ipynb #7cd48aa5
98
98
  def norm_sse_event(ev, **kwargs):
99
99
  "Normalize OpenAI Responses API stream event into Delta."
@@ -107,6 +107,7 @@ def norm_sse_event(ev, **kwargs):
107
107
  item = ev.get("item", {})
108
108
  itype = item.get("type", "")
109
109
  if itype == "function_call" or itype.endswith("_call"): return Delta(tool_calls=[norm_tool_call(item)], raw=ev, **kwargs)
110
+ return Delta(raw=ev, **kwargs)
110
111
  if typ in ("response.completed", "response.incomplete"):
111
112
  resp = ev.get("response", {})
112
113
  return Delta(finish_reason=norm_finish(resp), usage=norm_usage(resp), raw=ev, **kwargs)
@@ -118,6 +119,11 @@ def delta_index_fn(d, typ, last_typ, last_idx):
118
119
  idx = nested_idx(d, 'raw', 'output_index')
119
120
  return idx, idx
120
121
 
122
+ # %% ../nbs/02_oai_responses.ipynb #689e31b0
123
+ def collate_raw(deltas):
124
+ "The output items rebuilt from a stream's events"
125
+ return [d.raw['item'] for d in deltas if d.raw.get('type') == 'response.output_item.done']
126
+
121
127
  # %% ../nbs/02_oai_responses.ipynb #021b5257
122
128
  @delegates(mk_acollect_stream, but=['index_fn', 'api_name'])
123
129
  async def acollect_stream(resp, **kwargs):
@@ -137,20 +143,12 @@ def denorm_tool(m:Msg): return [denorm_tool_result(p) for p in m.content if isin
137
143
 
138
144
  # %% ../nbs/02_oai_responses.ipynb #67c8de13
139
145
  def denorm_assistant(m:Msg):
140
- items, srv_call_id = [], None
146
+ "The output items as the API sent them, else the message's text and tool calls as items"
147
+ if m.raw: return m.raw
148
+ items = []
141
149
  for p in m.content:
142
- if isinstance(p, ToolUse):
143
- items.append(denorm_tool_use(p))
144
- # TODO: Server tcs are excluded during deserialization in lisette, so this
145
- # is not used. Anthropic checks for `srvtoolu` and requires the full tool result metadata
146
- # which is a lot to store.
147
- # Instead of reconstructing server tool calls as actual tool uses we can make it a regular msg
148
- # telling AI a server tool was executed
149
- if p.server:
150
- srv_txt = f"[Server tool `{p.name}` executed successfully, results are generated below]"
151
- items.append(dict(type='function_call_output', call_id=p.id, output=srv_txt))
152
- elif isinstance(p, (Text, Thinking)):
153
- items.append(dict(type="message", role="assistant", content=[dict(type="output_text", text=p.text)]))
150
+ if isinstance(p, ToolUse): items.append(denorm_tool_use(p))
151
+ elif isinstance(p, Text): items.append(dict(type="message", role="assistant", content=[dict(type="output_text", text=p.text or '')]))
154
152
  return items
155
153
 
156
154
  # %% ../nbs/02_oai_responses.ipynb #64883b99
@@ -256,7 +254,10 @@ def mk_payload(msgs, model, **kwargs):
256
254
  return payload
257
255
 
258
256
  # %% ../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')}"}
257
+ def get_hdrs(api_key=None, oauth_token=None):
258
+ "Bearer auth: an OAuth access token, or else an API key from the argument or the environment"
259
+ return {"Authorization": f"Bearer {oauth_token or get_api_key(api_key, 'OPENAI_API_KEY')}"}
260
+
260
261
 
261
262
  # %% ../nbs/02_oai_responses.ipynb #a907ffa8
262
263
  def cost(usage, m):
@@ -277,6 +278,7 @@ def fix_payload(payload, model, vendor_name):
277
278
 
278
279
  # %% ../nbs/02_oai_responses.ipynb #07114b55
279
280
  api_ns = dict(norm_tool_calls=norm_tool_calls, norm_parts=norm_parts, norm_finish=norm_finish, norm_usage=norm_usage,
280
- supports_previous_response_id=True, acollect_stream=acollect_stream, mk_payload=mk_payload, cost=cost, get_hdrs=get_hdrs, fix_payload=fix_payload,
281
+ supports_previous_response_id=True, raw_msg=raw_msg, collate_raw=collate_raw, acollect_stream=acollect_stream, mk_payload=mk_payload,
282
+ cost=cost, get_hdrs=get_hdrs, fix_payload=fix_payload,
281
283
  endpoint='/responses')
282
284
  api_registry.register('openai', **api_ns)
fastllm/responses.py CHANGED
@@ -143,7 +143,7 @@ def response_object(rid, body, comp, previous_response_id=None, created_at=None)
143
143
  # %% ../nbs/06a_responses.ipynb #73a2fc16
144
144
  class ResponseState(BasicRepr):
145
145
  "Canonical history and optional provider continuation metadata."
146
- def __init__(self, id, model, history, provider_response_id=None, provider_response_reusable=True): store_attr()
146
+ def __init__(self, id, model, history, provider_response_id=None): store_attr()
147
147
 
148
148
  # %% ../nbs/06a_responses.ipynb #c9ca3744
149
149
  class ResponseTurn(BasicRepr):
@@ -203,7 +203,7 @@ def state(self:AsyncResponses, turn, comp, results=()):
203
203
  "Build the next continuation state; `results` are server tool results answering `comp`'s calls."
204
204
  normalize_call_ids(comp)
205
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)
206
+ return ResponseState(turn.id, turn.body['model'], history, comp.response_id)
207
207
 
208
208
 
209
209
  # %% ../nbs/06a_responses.ipynb #7615b274
fastllm/streaming.py CHANGED
@@ -12,13 +12,12 @@ from fastcore.utils import *
12
12
  from fastcore.meta import delegates
13
13
  from fasttransport.errors import *
14
14
  from .types import *
15
- from aidialog.msg_parts import Part, PartType, Msg, Text, Thinking, ToolUse, ToolResult, ServerToolResult, mk_part, fence_call_re, Completion
15
+ from aidialog.msg_parts import Part, PartType, Msg, Text, Thinking, ToolUse, ToolResult, mk_part, fence_call_re, Completion
16
16
 
17
17
  # %% ../nbs/01_streaming.ipynb #400d628a
18
18
  class Delta(BasicRepr):
19
19
  "Normalized streaming delta event."
20
- def __init__(self, text='', thinking='', refusal='', tool_calls=None, citations=None,
21
- server_tool_result=None, finish_reason=None, usage=None, raw=None):
20
+ def __init__(self, text='', thinking='', refusal='', tool_calls=None, citations=None, finish_reason=None, usage=None, raw=None):
22
21
  if tool_calls is None: tool_calls = []
23
22
  if citations is None: citations = []
24
23
  if raw is None: raw = {}
@@ -172,11 +171,7 @@ async def mk_acollect_stream(it, index_fn, model=None, api_name=None, vendor_nam
172
171
  except json.JSONDecodeError: continue
173
172
  acc.arguments = args
174
173
  yield acc
175
- # Server tool results for anthropic are yielded in d.server_tool_result by checking injected dummy `_delta`
176
174
  if acc.server: yield ToolResult(id=acc.id, name=acc.name, arguments=args, server=True, text="Server tool call executed.")
177
- if d.server_tool_result:
178
- idx = _fidx(d, 'server_tool_result')
179
- part_accum.parts[idx] = ServerToolResult(raw=d.server_tool_result)
180
175
  r = _proc(d, 'refusal')
181
176
  if r[0]: yield _mk_delta_part('refusal', r[0]['refusal'])
182
177
  if d.finish_reason: fin = d.finish_reason
@@ -190,6 +185,6 @@ async def mk_acollect_stream(it, index_fn, model=None, api_name=None, vendor_nam
190
185
  fin = FinishReason.tool_calls if fin==FinishReason.stop and any(~L(tcs).attrgot('server')) else fin # recheck tool calls post collation
191
186
  # tool calls and non-anthropic citations are yielded at the end
192
187
  yield Completion(model,
193
- message=Msg(role="assistant", content=parts),
188
+ message=Msg(role="assistant", content=parts, raw=api_registry[api_name].collate_raw(deltas) if api_name else None),
194
189
  finish_reason=fin, usage=usg, api_name=api_name, vendor_name=vendor_name,
195
190
  raw={'deltas':deltas})
fastllm/types.py CHANGED
@@ -68,7 +68,8 @@ FinishReason = str_enum('finish_reason', 'stop', 'tool_calls', 'length', 'conten
68
68
  # %% ../nbs/00_types.ipynb #fc681c52
69
69
  class APIRegistry(dict):
70
70
  _eps = None
71
- def register(self, name, finalize_usage=noop, **kwargs): self[name] = SimpleNamespace(finalize_usage=finalize_usage, **kwargs)
71
+ def register(self, name, finalize_usage=noop, raw_msg=noop, collate_raw=noop, **kwargs):
72
+ self[name] = SimpleNamespace(finalize_usage=finalize_usage, raw_msg=raw_msg, collate_raw=collate_raw, **kwargs)
72
73
  def _load(self, name):
73
74
  "Import the plugin providing api `name` from the `fastllm.apis` entry-point group; its import registers the api."
74
75
  if self._eps is None: self._eps = {o.name:o for o in entry_points(group='fastllm.apis')}
@@ -90,7 +91,7 @@ def mk_completion(resp, model, api_name, vendor_name):
90
91
  tcs = api.norm_tool_calls(resp)
91
92
  parts = api.norm_parts(resp)
92
93
  usg = api.finalize_usage(api.norm_usage(resp), parts)
93
- return Completion(model=model, message=Msg(role="assistant", content=parts), finish_reason=api.norm_finish(resp, tcs),
94
+ return Completion(model=model, message=Msg(role="assistant", content=parts, raw=api.raw_msg(resp)), finish_reason=api.norm_finish(resp, tcs),
94
95
  usage=usg, api_name=api_name, vendor_name=vendor_name, raw=resp)
95
96
 
96
97
  # %% ../nbs/00_types.ipynb #8a8e468b
@@ -300,14 +301,14 @@ register_model_info('muse-spark-1.1', vendor_name='meta_ai', **modern_llm, max_i
300
301
  codex_pricing = dict(input_cost_per_token = 0.10/1_000_000, output_cost_per_token = 0.50/1_000_000,
301
302
  cache_creation_input_token_cost = 0.10/1_000_000, cache_read_input_token_cost = 0.10/1_000_000)
302
303
 
303
- def _rm_ctx_tiers(vendor_name, model):
304
- "Drop upstream context-tier rates, which would otherwise override flat subscription pricing"
304
+ def _flat_rates(vendor_name, model):
305
+ "Keep only the flat subscription rates: drop the upstream context, cache-TTL and service-tier variants that would otherwise apply"
305
306
  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]
307
+ 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
308
 
308
309
  for model in (codex54, codex54m, codex55):
309
310
  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)
311
+ _flat_rates('codex', model)
311
312
 
312
313
  register_model_info(codex53spark, 'codex', **codex_pricing,
313
314
  supports_vision=False, supports_image_input=False, supports_web_search=True, supports_reasoning=True, supports_function_calling=True,
@@ -318,6 +319,7 @@ register_model_info(codex53spark, 'codex', **codex_pricing,
318
319
  register_model_info(fable51, 'anthropic', base=fable5)
319
320
  for model in (haik45, sonn45, sonn46, sonn5, opus46, opus48, opus5, fable5, fable51):
320
321
  register_model_info(model, 'claude_code', base=model, base_vendor_name='anthropic', **codex_pricing)
322
+ _flat_rates('claude_code', model)
321
323
 
322
324
  # %% ../nbs/00_types.ipynb #bb0c4c2a
323
325
  sol,terra,luna = gpt56s = 'gpt-5.6-sol gpt-5.6-terra gpt-5.6-luna'.split()
@@ -325,7 +327,7 @@ sol,terra,luna = gpt56s = 'gpt-5.6-sol gpt-5.6-terra gpt-5.6-luna'.split()
325
327
  # Its window is smaller than the API's: 371,331 input tokens is accepted and 371,981 is not, on all three.
326
328
  for model in gpt56s:
327
329
  register_model_info(model, 'codex', base=model, base_vendor_name='openai', max_input_tokens=371_000, **codex_pricing)
328
- _rm_ctx_tiers('codex', model)
330
+ _flat_rates('codex', model)
329
331
 
330
332
  # %% ../nbs/00_types.ipynb #24cc47ec
331
333
  def get_model_pricing(mn, vendor_name, million=True):
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-fastllm
3
- Version: 0.0.48
3
+ Version: 0.0.50
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
@@ -10,7 +10,7 @@ Classifier: Programming Language :: Python :: 3 :: Only
10
10
  Requires-Python: >=3.10
11
11
  Description-Content-Type: text/markdown
12
12
  Requires-Dist: fastcore>=2.1.18
13
- Requires-Dist: aidialog>=0.0.10
13
+ Requires-Dist: aidialog>=0.0.24
14
14
  Requires-Dist: fasttransport>=0.0.2
15
15
  Requires-Dist: pillow
16
16
 
@@ -21,13 +21,14 @@ Requires-Dist: pillow
21
21
 
22
22
  ## Install
23
23
 
24
- Clone and install locally into your `aai-ws` env
24
+ Run `pip install python-fastllm` or clone from github and install locally.
25
25
 
26
26
  ## Setup
27
27
 
28
28
  ``` python
29
29
  from aidialog.msg_parts import Msg, Part, Text, Thinking, ToolUse, InputImage, mk_tool_res_msg, Completion
30
30
  from fastllm.acomplete import acomplete
31
+ from fastllm.types import sample_img_url
31
32
  import asyncio, json
32
33
 
33
34
  # Helpers
@@ -219,6 +220,7 @@ print("\nNo tools: ", end='')
219
220
  r = await stream([user("What's the weather?")], model='claude-sonnet-4-20250514', tools=tools, tool_choice='none', max_tokens=mtok)
220
221
  ```
221
222
 
223
+
222
224
  - ⏳ `get_weather(city="<UNKNOWN>")` ⏳
223
225
 
224
226
  Forced: [ToolUse(raw={'caller': {'type': 'direct'}}, cache_control=None, id='toolu_01U7tAXjXAtwjp6xNSMbsyPU', name='get_weather', arguments={'city': '<UNKNOWN>'}, server=False, text=None)]
@@ -381,9 +383,8 @@ print(f"Usage: {r2.usage}")
381
383
  Send images to any provider that supports them. The canonical `input_image` part works everywhere:
382
384
 
383
385
  ``` python
384
- img_url = "https://img.freepik.com/free-photo/mountain-range-body-water_53876-139760.jpg?semt=ais_hybrid&w=740&q=80"
385
386
  img_msg = Msg(role='user', content=[
386
- InputImage(img_url),
387
+ InputImage(sample_img_url),
387
388
  Text("What do you see in this image?")
388
389
  ])
389
390
 
@@ -0,0 +1,17 @@
1
+ fastllm/__init__.py,sha256=49eWk0NL1c-ZrseVvC8ayVNYEyk7v6rCK0Q5Z4EQB64,131
2
+ fastllm/_modidx.py,sha256=o9foY201ZEAz0AR-9VkRUdpALNA8Jv6WYDs797YRyyw,43700
3
+ fastllm/acomplete.py,sha256=aP1ff9a7WKNhBd1kwKE2TJ-YyihLcdhd9-1Izi4wQP4,10772
4
+ fastllm/anthropic.py,sha256=0mmTjn9ZoEP6bBJki3YaCE1Ck97Q5qT06BZNGH1OMtg,17706
5
+ fastllm/chat.py,sha256=klji3SOYs5xyzrSFbNhy3uoQ2TSZ3a-8HTjvdRtaG5Q,25036
6
+ fastllm/codex.py,sha256=r6auZ8egia83aGkWcF4132AzW4SsBvKeDwg_CxyXj9E,5808
7
+ fastllm/gemini.py,sha256=zbxNmI1cMUJJ10xIfBqPqh38hAQ0exsFKkhTXvG2-eE,16281
8
+ fastllm/openai_chat.py,sha256=n5INfDCsAZIiKUImWeaX6SJ_OAJ_uMhw20HUkgFgdgM,12379
9
+ fastllm/openai_responses.py,sha256=zsaOjkQUZ-7iuGPc-UpvnM6oAyqW09BDYwZ_ma4-1Qo,14346
10
+ fastllm/responses.py,sha256=kRKXISv2c36wIM8EOF68p7c41VZ_Rpn9LdDmByPRf-Q,18707
11
+ fastllm/streaming.py,sha256=i_FU_Mt-3F2lBEImYav8I5pEjUSKYk3JFIW3lkiXEb8,8813
12
+ fastllm/types.py,sha256=MR8ukG3cuBXTbExBKHsZCa1GzCzzJ1Fgu-L9yxOnjtk,19365
13
+ python_fastllm-0.0.50.dist-info/METADATA,sha256=BTPOAD6e-hgHew_i7ITJMwqfY6C0t-H58y1cmEQKAjo,21101
14
+ python_fastllm-0.0.50.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
15
+ python_fastllm-0.0.50.dist-info/entry_points.txt,sha256=WkWd04F3xVYFP5bsexaQe9Se6uelwE5ULoTtHWreO0s,133
16
+ python_fastllm-0.0.50.dist-info/top_level.txt,sha256=F8qodL7nEGUHGmzzqfhNKCTIr1i0D6cvudOnm3z7o0Y,8
17
+ python_fastllm-0.0.50.dist-info/RECORD,,
@@ -1,17 +0,0 @@
1
- fastllm/__init__.py,sha256=ioW91VJRYfSiEMjolhjzoNX1vPYfkbDKdrHXp79B9cQ,131
2
- fastllm/_modidx.py,sha256=jF1SdJd1qbfI_ksSmsrjKUgRHGrANOpxyqTn_hcZ2LY,42836
3
- fastllm/acomplete.py,sha256=Fb1GWkU6F7-bllbEEp7TPOzqmoWp309i5sJqYJbU6PM,10478
4
- fastllm/anthropic.py,sha256=udojlLuritp90JQlH-dGHcWRpzgnO1mZYFYtbt2F5IA,17015
5
- fastllm/chat.py,sha256=W8PPpZ95Az7wzLgQ15u3HMroWBVXDoaUz871f0x7cEk,24842
6
- fastllm/codex.py,sha256=r6auZ8egia83aGkWcF4132AzW4SsBvKeDwg_CxyXj9E,5808
7
- fastllm/gemini.py,sha256=DveWJNd78Pf2omC_t46iOzT84a-Lo3kWxchCKR3WsCA,15135
8
- fastllm/openai_chat.py,sha256=f11U-iaxGbBq0-Rjz17GhA1Vame5n9kMqsk1gkfDtIQ,11633
9
- fastllm/openai_responses.py,sha256=MUIBZY447tJ4Lc6saBPrVolXe8cdQdMK58YOtaJUHYk,14482
10
- fastllm/responses.py,sha256=jcbKUwbEIsy7ngNcHUtv2bfYeDgqSysK_qWM-0LC9Mg,18767
11
- fastllm/streaming.py,sha256=8qVtWeZ7NlP-9rup_4Ho0ol2dx3-XuGcIMXpZ1GYdEo,9079
12
- fastllm/types.py,sha256=3qXyiS431AlSSn8GyGCNDflZxMWPeIWirp8TIyhIu_A,19146
13
- python_fastllm-0.0.48.dist-info/METADATA,sha256=meHtTkX0PRsmljM1a72zXLGwlFHm1IKkjroJKnAmc1g,21143
14
- python_fastllm-0.0.48.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
15
- python_fastllm-0.0.48.dist-info/entry_points.txt,sha256=WkWd04F3xVYFP5bsexaQe9Se6uelwE5ULoTtHWreO0s,133
16
- python_fastllm-0.0.48.dist-info/top_level.txt,sha256=F8qodL7nEGUHGmzzqfhNKCTIr1i0D6cvudOnm3z7o0Y,8
17
- python_fastllm-0.0.48.dist-info/RECORD,,