python-fastllm 0.0.49__py3-none-any.whl → 0.0.51__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.49"
5
+ __version__ = "0.0.51"
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,9 @@ 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'),
55
+ 'fastllm.anthropic.search_hits': ('anthropic.html#search_hits', 'fastllm/anthropic.py')},
53
56
  'fastllm.chat': { 'fastllm.chat.AsyncChat': ('chat.html#asyncchat', 'fastllm/chat.py'),
54
57
  'fastllm.chat.AsyncChat.__call__': ('chat.html#asyncchat.__call__', 'fastllm/chat.py'),
55
58
  'fastllm.chat.AsyncChat.__init__': ('chat.html#asyncchat.__init__', 'fastllm/chat.py'),
@@ -139,6 +142,7 @@ d = { 'settings': { 'branch': 'main',
139
142
  'fastllm.gemini': { 'fastllm.gemini._gem_filter_sch': ('gemini.html#_gem_filter_sch', 'fastllm/gemini.py'),
140
143
  'fastllm.gemini._gem_part_type': ('gemini.html#_gem_part_type', 'fastllm/gemini.py'),
141
144
  'fastllm.gemini.acollect_stream': ('gemini.html#acollect_stream', 'fastllm/gemini.py'),
145
+ 'fastllm.gemini.collate_raw': ('gemini.html#collate_raw', 'fastllm/gemini.py'),
142
146
  'fastllm.gemini.cost': ('gemini.html#cost', 'fastllm/gemini.py'),
143
147
  'fastllm.gemini.delta_index_fn': ('gemini.html#delta_index_fn', 'fastllm/gemini.py'),
144
148
  'fastllm.gemini.denorm_assistant': ('gemini.html#denorm_assistant', 'fastllm/gemini.py'),
@@ -163,8 +167,10 @@ d = { 'settings': { 'branch': 'main',
163
167
  'fastllm.gemini.norm_parts': ('gemini.html#norm_parts', 'fastllm/gemini.py'),
164
168
  'fastllm.gemini.norm_sse_event': ('gemini.html#norm_sse_event', 'fastllm/gemini.py'),
165
169
  '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')},
170
+ 'fastllm.gemini.norm_usage': ('gemini.html#norm_usage', 'fastllm/gemini.py'),
171
+ 'fastllm.gemini.raw_msg': ('gemini.html#raw_msg', 'fastllm/gemini.py')},
167
172
  'fastllm.openai_chat': { 'fastllm.openai_chat.acollect_stream': ('oai_chat.html#acollect_stream', 'fastllm/openai_chat.py'),
173
+ 'fastllm.openai_chat.collate_raw': ('oai_chat.html#collate_raw', 'fastllm/openai_chat.py'),
168
174
  'fastllm.openai_chat.cost': ('oai_chat.html#cost', 'fastllm/openai_chat.py'),
169
175
  'fastllm.openai_chat.delta_index_fn': ('oai_chat.html#delta_index_fn', 'fastllm/openai_chat.py'),
170
176
  'fastllm.openai_chat.denorm_assistant': ('oai_chat.html#denorm_assistant', 'fastllm/openai_chat.py'),
@@ -189,14 +195,15 @@ d = { 'settings': { 'branch': 'main',
189
195
  'fastllm.openai_chat.norm_finish': ('oai_chat.html#norm_finish', 'fastllm/openai_chat.py'),
190
196
  'fastllm.openai_chat.norm_parts': ('oai_chat.html#norm_parts', 'fastllm/openai_chat.py'),
191
197
  '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')},
198
+ 'fastllm.openai_chat.norm_tool_calls': ('oai_chat.html#norm_tool_calls', 'fastllm/openai_chat.py'),
199
+ 'fastllm.openai_chat.raw_msg': ('oai_chat.html#raw_msg', 'fastllm/openai_chat.py')},
193
200
  'fastllm.openai_responses': { 'fastllm.openai_responses.Completion.response_id': ( 'oai_responses.html#completion.response_id',
194
201
  '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
202
  'fastllm.openai_responses._sanid': ('oai_responses.html#_sanid', 'fastllm/openai_responses.py'),
198
203
  'fastllm.openai_responses.acollect_stream': ( 'oai_responses.html#acollect_stream',
199
204
  'fastllm/openai_responses.py'),
205
+ 'fastllm.openai_responses.collate_raw': ( 'oai_responses.html#collate_raw',
206
+ 'fastllm/openai_responses.py'),
200
207
  'fastllm.openai_responses.cost': ('oai_responses.html#cost', 'fastllm/openai_responses.py'),
201
208
  'fastllm.openai_responses.delta_index_fn': ( 'oai_responses.html#delta_index_fn',
202
209
  'fastllm/openai_responses.py'),
@@ -245,13 +252,19 @@ d = { 'settings': { 'branch': 'main',
245
252
  'fastllm.openai_responses.norm_tool_calls': ( 'oai_responses.html#norm_tool_calls',
246
253
  'fastllm/openai_responses.py'),
247
254
  'fastllm.openai_responses.norm_usage': ( 'oai_responses.html#norm_usage',
248
- 'fastllm/openai_responses.py')},
255
+ 'fastllm/openai_responses.py'),
256
+ 'fastllm.openai_responses.raw_msg': ( 'oai_responses.html#raw_msg',
257
+ 'fastllm/openai_responses.py')},
249
258
  'fastllm.responses': { 'fastllm.responses.AsyncResponses': ('responses.html#asyncresponses', 'fastllm/responses.py'),
259
+ 'fastllm.responses.AsyncResponses.__init__': ( 'responses.html#asyncresponses.__init__',
260
+ 'fastllm/responses.py'),
250
261
  'fastllm.responses.AsyncResponses.call': ('responses.html#asyncresponses.call', 'fastllm/responses.py'),
251
262
  'fastllm.responses.AsyncResponses.events': ( 'responses.html#asyncresponses.events',
252
263
  'fastllm/responses.py'),
253
264
  'fastllm.responses.AsyncResponses.prepare': ( 'responses.html#asyncresponses.prepare',
254
265
  'fastllm/responses.py'),
266
+ 'fastllm.responses.AsyncResponses.serve': ( 'responses.html#asyncresponses.serve',
267
+ 'fastllm/responses.py'),
255
268
  'fastllm.responses.AsyncResponses.server_results': ( 'responses.html#asyncresponses.server_results',
256
269
  'fastllm/responses.py'),
257
270
  'fastllm.responses.AsyncResponses.server_turn': ( 'responses.html#asyncresponses.server_turn',
fastllm/anthropic.py CHANGED
@@ -1,21 +1,21 @@
1
1
  # AUTOGENERATED! DO NOT EDIT! File to edit: ../nbs/04_anthropic.ipynb.
2
2
 
3
3
  # %% auto #0
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']
4
+ __all__ = ['ant_tc_types', 'norm_tool_call', 'norm_tool_calls', 'norm_usage', 'finalize_usage', 'norm_finish', 'search_hits',
5
+ 'norm_parts', 'raw_msg', 'norm_tr_parts', 'norm_sse_event', 'delta_index_fn', 'collate_raw',
6
+ 'acollect_stream', '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, Completion, 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,20 +67,31 @@ 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)
71
+
72
+ def search_hits(parts, blocks):
73
+ "Set each server call's `text` to the `title: url` lines of its result block, when the response has one"
74
+ calls = {p.id: p for p in parts if isinstance(p, ToolUse)}
75
+ for b in blocks:
76
+ if b.get('type', '').endswith('_tool_result') and (tc := calls.get(b.get('tool_use_id'))):
77
+ tc.text = '\n'.join(f"{r.get('title', '')}: {r.get('url', '')}" for r in b.get('content') or [] if isinstance(r, dict) and r.get('url'))
78
+ return parts
73
79
 
74
80
  def norm_parts(resp):
81
+ "Canonical parts of a response; a server tool's result block lives in the raw message, and as the call's `text`"
75
82
  parts = []
76
83
  for b in resp.get("content", []):
77
84
  typ = _ant_part_type(b.get("type", "text"))
78
85
  if typ == PartType.thinking: parts.append(Thinking(b.get("thinking", ""), raw=b))
79
86
  elif typ == PartType.tool_use:
80
87
  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))
83
- return parts
88
+ elif typ == PartType.text: parts.append(Text(b.get("text", ""), raw=b))
89
+ return search_hits(parts, resp.get("content", []))
90
+
91
+ # %% ../nbs/04_anthropic.ipynb #f24e52db
92
+ def raw_msg(resp):
93
+ "The assistant message as the API sent it"
94
+ return dict(role='assistant', content=resp['content'])
84
95
 
85
96
  # %% ../nbs/04_anthropic.ipynb #5d1440b7
86
97
  def norm_tr_parts(content):
@@ -103,7 +114,6 @@ def norm_sse_event(ev, **kwargs):
103
114
  text, thinking, tcs, citations = None, None, [], None
104
115
  if typ == "content_block_start":
105
116
  cb = ev.get("content_block", {})
106
- if cb.get("type", "").endswith("_tool_result"): return Delta(server_tool_result=cb, raw=ev, **kwargs)
107
117
  if tc := norm_tool_call(cb):
108
118
  if not tc.arguments: tc.arguments = {'_delta': ''}
109
119
  tcs = [tc]
@@ -127,11 +137,30 @@ def delta_index_fn(d, typ, last_typ, last_idx):
127
137
  'Returns accumulation index for current delta and updated last idx'
128
138
  return nested_idx(d, 'raw', 'index'), None
129
139
 
140
+ # %% ../nbs/04_anthropic.ipynb #de900e34
141
+ def collate_raw(deltas):
142
+ "The assistant message rebuilt from a stream's events"
143
+ blocks = {}
144
+ for ev in L(deltas).attrgot('raw'):
145
+ typ,i = ev.get('type'),ev.get('index')
146
+ if typ == 'content_block_start': blocks[i] = copy.deepcopy(ev['content_block'])
147
+ elif typ == 'content_block_delta':
148
+ d,b = ev['delta'],blocks[i]
149
+ if d['type'] == 'text_delta': b['text'] += d['text']
150
+ elif d['type'] == 'thinking_delta': b['thinking'] += d['thinking']
151
+ elif d['type'] == 'signature_delta': b['signature'] = d['signature']
152
+ elif d['type'] == 'input_json_delta': b['_json'] = b.get('_json', '') + d['partial_json']
153
+ elif d['type'] == 'citations_delta': b['citations'] = (b.get('citations') or []) + [d['citation']]
154
+ elif typ == 'content_block_stop' and '_json' in blocks[i]: blocks[i]['input'] = json.loads(blocks[i].pop('_json') or '{}')
155
+ return dict(role='assistant', content=list(blocks.values()))
156
+
130
157
  # %% ../nbs/04_anthropic.ipynb #c994adb1
131
158
  @delegates(mk_acollect_stream, but=['index_fn', 'api_name'])
132
159
  async def acollect_stream(resp, **kwargs):
133
160
  res = mk_acollect_stream(norm_and_yield(resp, norm_sse_event), index_fn=delta_index_fn, api_name='anthropic', **kwargs)
134
- async for o in res: yield o
161
+ async for o in res:
162
+ if isinstance(o, Completion): search_hits(o.message.content, o.message.raw['content'])
163
+ yield o
135
164
 
136
165
  # %% ../nbs/04_anthropic.ipynb #bb11ab62
137
166
  def _ant_cc(block, p):
@@ -145,44 +174,29 @@ def _sanid(id_str): return re.sub(r'[^a-zA-Z0-9_-]', '_', id_str or '')
145
174
  # %% ../nbs/04_anthropic.ipynb #6ec772cb
146
175
  def denorm_tool_use(p:ToolUse):
147
176
  "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)
177
+ return _ant_cc(dict(type='tool_use', id=_sanid(p.id or ''), name=p.name or '', input=p.arguments or {}), p)
151
178
 
152
179
  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
180
+ "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"
181
+ if m.raw:
182
+ cc = [p.cache_control for p in m.content if p.cache_control]
183
+ if not cc: return m.raw
184
+ return m.raw | dict(content=m.raw['content'][:-1] + [m.raw['content'][-1] | dict(cache_control=cc[-1])])
185
+ 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))]
186
+ return dict(role='assistant', content=blocks)
173
187
 
174
188
  def denorm_tool(m:Msg):
175
189
  "Convert canonical tool Msg to Anthropic user message with tool_result blocks."
176
190
  blocks = [denorm_tool_result(p) for p in m.content if isinstance(p, ToolResult)]
177
- return [dict(role='user', content=blocks)]
191
+ return dict(role='user', content=blocks)
178
192
 
179
193
  def denorm_msgs(msgs:list[Msg]):
180
194
  "Convert list of canonical Msgs to Anthropic messages."
181
195
  res = []
182
196
  for m in msgs:
183
197
  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))
198
+ elif m.role == 'assistant': res.append(denorm_assistant(m))
199
+ elif m.role == 'tool': res.append(denorm_tool(m))
186
200
  return res
187
201
 
188
202
  # %% ../nbs/04_anthropic.ipynb #f762a6f2
@@ -346,5 +360,6 @@ def cost(usage, m):
346
360
 
347
361
  # %% ../nbs/04_anthropic.ipynb #f7c0b989
348
362
  api_registry.register('anthropic', norm_tool_calls=norm_tool_calls, norm_parts=norm_parts, norm_finish=norm_finish, norm_usage=norm_usage,
349
- finalize_usage=finalize_usage, acollect_stream=acollect_stream, mk_payload=mk_payload, cost=cost, get_hdrs=get_hdrs,
363
+ finalize_usage=finalize_usage, raw_msg=raw_msg, collate_raw=collate_raw, acollect_stream=acollect_stream, mk_payload=mk_payload,
364
+ cost=cost, get_hdrs=get_hdrs,
350
365
  endpoint='/v1/messages')
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):
@@ -301,6 +317,8 @@ api_ns = dict(norm_tool_calls=norm_tool_calls,
301
317
  norm_finish=norm_finish,
302
318
  norm_usage=norm_usage,
303
319
  acollect_stream=acollect_stream,
320
+ raw_msg=raw_msg,
321
+ collate_raw=collate_raw,
304
322
  mk_payload=mk_payload,
305
323
  cost=cost,
306
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
 
@@ -226,6 +239,6 @@ def fix_payload(payload, model, vendor_name):
226
239
 
227
240
  # %% ../nbs/03_oai_chat.ipynb #e2b0908e
228
241
  api_ns = dict(norm_tool_calls=norm_tool_calls, norm_parts=norm_parts, norm_finish=norm_finish, norm_usage=norm_usage,
229
- 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,
230
243
  endpoint='/chat/completions')
231
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
@@ -280,6 +278,7 @@ def fix_payload(payload, model, vendor_name):
280
278
 
281
279
  # %% ../nbs/02_oai_responses.ipynb #07114b55
282
280
  api_ns = dict(norm_tool_calls=norm_tool_calls, norm_parts=norm_parts, norm_finish=norm_finish, norm_usage=norm_usage,
283
- 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,
284
283
  endpoint='/responses')
285
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):
@@ -160,6 +160,7 @@ class ResponseTurn(BasicRepr):
160
160
  # %% ../nbs/06a_responses.ipynb #a648297f
161
161
  class AsyncResponses:
162
162
  "Run individual Responses turns over FastLLM."
163
+ def __init__(self): self.tasks = set() # served turns still running
163
164
  def prepare(self, body, previous=None, server_tools=None, **kwargs):
164
165
  if not isinstance(body, dict): raise ResponsesError('Request body must be an object')
165
166
  model = body.get('model')
@@ -203,7 +204,7 @@ def state(self:AsyncResponses, turn, comp, results=()):
203
204
  "Build the next continuation state; `results` are server tool results answering `comp`'s calls."
204
205
  normalize_call_ids(comp)
205
206
  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
+ return ResponseState(turn.id, turn.body['model'], history, comp.response_id)
207
208
 
208
209
 
209
210
  # %% ../nbs/06a_responses.ipynb #7615b274
@@ -343,3 +344,21 @@ async def events(self:AsyncResponses, turn, finalize=None, stream=None):
343
344
  except ResponsesError as exc: yield state.event('error', code=exc.code, message=str(exc), param=exc.param)
344
345
  except APIError as exc: yield state.event('error', code=exc.code or 'provider_error', message=str(exc), param=None)
345
346
 
347
+
348
+ # %% ../nbs/06a_responses.ipynb #cc32ce40
349
+ @patch
350
+ def serve(self:AsyncResponses, turn, finalize=None, stream=None, timeout=None):
351
+ "The frames of `events`, from a task that outlives its reader, so a disconnected client still gets its turn finalized"
352
+ q = asyncio.Queue()
353
+ async def _run():
354
+ try:
355
+ async with asyncio.timeout(timeout):
356
+ async for frame in self.events(turn, finalize=finalize, stream=stream): q.put_nowait(frame)
357
+ except TimeoutError: q.put_nowait(response_event('error', 0, code='timeout', message=f'turn exceeded {timeout}s', param=None))
358
+ finally: q.put_nowait(None)
359
+ t = asyncio.create_task(_run())
360
+ self.tasks.add(t)
361
+ t.add_done_callback(self.tasks.discard)
362
+ async def _frames():
363
+ while (frame := await q.get()) is not None: yield frame
364
+ return _frames()
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
@@ -3,14 +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', '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']
6
+ __all__ = ['FinishReason', 'api_registry', 'model_prices_url', 'sample_img_url', 'sample_doms', 'haik45', 'sonn45', 'sonn46',
7
+ 'sonn', 'sonn5', 'opus46', 'opus48', 'opus', 'opus5', 'fable', 'fable5', 'fable51', 'gpt54', 'gpt54m',
8
+ 'gpt55', 'codex54', 'codex54m', 'codex55', 'codex53spark', 'model_info_registry', 'modern_llm',
9
+ 'effort_codes', 'deepseek_v4_common', 'deepseek_v4_flash_prices', 'mimo_v25_common', 'codex_pricing', 'sol',
10
+ 'terra', 'luna', 'gpt56s', 'Usage', 'APIRegistry', 'mk_completion', 'fn_schema', 'payload_kwargs',
11
+ 'provider_req', 'get_api_key', 'wrap_typed', 'unwrap_typed', 'resize_b64', 'model_prices_meta',
12
+ 'infer_api_name', 'get_model_meta', 'register_model_info', 'get_model_info', 'effort_code', 'effort_levels',
13
+ 'resolve_effort', 'get_model_pricing', 'approx_pricing', 'is_deepseek_peak_hour', 'price_tier', 'tier_rate']
14
14
 
15
15
  # %% ../nbs/00_types.ipynb #b4d047fd
16
16
  import httpx2, base64, io
@@ -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
@@ -169,6 +170,7 @@ def model_prices_meta():
169
170
 
170
171
  # %% ../nbs/00_types.ipynb #019882a3
171
172
  sample_img_url = "https://img.magnific.com/free-photo/mountain-range-body-water_53876-139760.jpg?semt=ais_hybrid&w=740&q=80"
173
+ sample_doms = ('img.magnific.com', 'arxiv.org', 'openaiassets.blob.core.windows.net') # hosts of the sample media the lessons fetch, for cachy
172
174
 
173
175
  # %% ../nbs/00_types.ipynb #68e488d8
174
176
  def infer_api_name(model):
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-fastllm
3
- Version: 0.0.49
3
+ Version: 0.0.51
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.25
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, sample_doms
31
32
  import asyncio, json
32
33
 
33
34
  # Helpers
@@ -56,7 +57,7 @@ The same `acomplete` call works with Claude, GPT, Gemini, and Kimi. Just change
56
57
 
57
58
  ``` python
58
59
  models = [
59
- ('claude-sonnet-4-20250514', {}),
60
+ ('claude-sonnet-5', {}),
60
61
  ('gpt-4o-mini', {}),
61
62
  ('models/gemini-3-flash-preview', {}),
62
63
  ('accounts/fireworks/models/kimi-k2p5', dict(vendor_name='fireworks_ai'))
@@ -80,46 +81,6 @@ for name, kw in models:
80
81
 
81
82
  The response should be simple and direct.
82
83
 
83
- ## Multi-Turn — Swap Providers Mid-Conversation
84
-
85
- Build a conversation with one provider, then seamlessly continue it with another. `fastllm` translates between every provider’s native format automatically:
86
-
87
- ``` python
88
- # Turn 1: Claude starts the conversation
89
- msgs = [user("Name the 3 largest planets in our solar system. One sentence.")]
90
- print("Claude: ", end='')
91
- r1 = await stream(msgs, model='claude-sonnet-4-20250514', max_tokens=mtok)
92
- ```
93
-
94
- Claude: The three largest planets in our solar system are Jupiter, Saturn, and Neptune.
95
-
96
- ``` python
97
- # Turn 2: Switch to GPT — just change the model string
98
- msgs += [r1.message, user("Which one has the most moons?")]
99
- print("GPT: ", end='')
100
- r2 = await stream(msgs, model='gpt-4o-mini', max_tokens=mtok)
101
- ```
102
-
103
- GPT: As of now, Saturn has the most moons, with over 80 confirmed moons.
104
-
105
- ``` python
106
- # Turn 3: Switch to Gemini — same msgs, different provider
107
- msgs += [r2.message, user("Summarize our conversation in one sentence.")]
108
- print("Gemini: ", end='')
109
- r3 = await stream(msgs, model='models/gemini-3-flash-preview', max_tokens=mtok)
110
- ```
111
-
112
- Gemini: The conversation identified the three largest planets in the solar system and noted that Saturn currently has the most moons.
113
-
114
- ``` python
115
- # Turn 4: Switch to Kimi — works the same way
116
- msgs += [r3.message, user("Thanks! What's one surprising fact about Saturn?")]
117
- print("Kimi: ", end='')
118
- r4 = await stream(msgs, model='accounts/fireworks/models/kimi-k2p5', vendor_name='fireworks_ai', max_tokens=mtok)
119
- ```
120
-
121
- Kimi: 🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠Saturn is less dense than water, meaning it would theoretically float if you could find a bathtub large enough to hold it.
122
-
123
84
  ## System Prompts
124
85
 
125
86
  Pass a system prompt to any provider — `fastllm` maps it to each API’s native mechanism (Anthropic `system`, OpenAI `instructions`, Gemini `system_instruction`):
@@ -128,7 +89,7 @@ Pass a system prompt to any provider — `fastllm` maps it to each API’s nativ
128
89
  sys = "You are a pirate chef. Always respond in pirate speak and mention food."
129
90
 
130
91
  print("Claude: ", end='')
131
- r = await stream([user("What should I do today?")], model='claude-sonnet-4-20250514', system=sys, max_tokens=mtok)
92
+ r = await stream([user("What should I do today?")], model='claude-sonnet-5', system=sys, max_tokens=mtok)
132
93
 
133
94
  print("Gemini: ", end='')
134
95
  r = await stream([user("What should I do today?")], model='models/gemini-3-flash-preview', system=sys, max_tokens=mtok)
@@ -161,7 +122,7 @@ r = await stream([user("What should I do today?")], model='models/gemini-3-flash
161
122
 
162
123
  ## Tool Calling — Define Once, Use Anywhere
163
124
 
164
- Define tools in a single canonical format. `fastllm` translates to each provider’s native tool schema automatically. Here we start a tool-use conversation with Claude, provide the result, then switch to GPT and Gemini to continue:
125
+ Define tools in a single canonical format. `fastllm` translates to each provider’s native tool schema automatically. Here Claude requests the tool, receives the result, and answers:
165
126
 
166
127
  ``` python
167
128
  tools = [{"type": "function", "function": {
@@ -172,7 +133,7 @@ tools = [{"type": "function", "function": {
172
133
 
173
134
  # Turn 1: Claude requests the tool
174
135
  msgs = [user("What's the weather in Paris?")]
175
- r1 = await stream(msgs, model='claude-sonnet-4-20250514', tools=tools, max_tokens=mtok)
136
+ r1 = await stream(msgs, model='claude-sonnet-5', tools=tools, max_tokens=mtok)
176
137
  print("Tool calls:", r1.tool_calls)
177
138
  ```
178
139
 
@@ -182,41 +143,26 @@ print("Tool calls:", r1.tool_calls)
182
143
  Tool calls: [ToolUse(raw={'caller': {'type': 'direct'}}, cache_control=None, id='toolu_01RMN1WM7vPBT3ovv5Ex6VzC', name='get_weather', arguments={'city': 'Paris'}, server=False, text=None)]
183
144
 
184
145
  ``` python
185
- # Provide the tool result
186
- msgs += [r1.message, mk_tool_res_msg(r1.tool_calls, ['22°C, sunny with light clouds'])]
187
-
188
- # Turn 2: Switch to GPT to interpret the result
189
- msgs.append(user("Should I bring a jacket?"))
190
- print("GPT: ", end='')
191
- r2 = await stream(msgs, model='gpt-4o-mini', tools=tools, max_tokens=mtok)
146
+ # Provide the tool result, then continue the same conversation
147
+ msgs += [r1.message, mk_tool_res_msg(r1.tool_calls, ['22°C, sunny with light clouds']), user("Should I bring a jacket?")]
148
+ print("Claude: ", end='')
149
+ r2 = await stream(msgs, model='claude-sonnet-5', tools=tools, max_tokens=mtok)
192
150
  ```
193
151
 
194
152
  GPT: With the temperature at 22°C and sunny with light clouds, a jacket isn't necessary for most people. However, if you tend to feel cold easily or if you plan to be out in the evening when it might cool down a bit, it could be a good idea to bring a light jacket.
195
153
 
196
- ``` python
197
- # Turn 3: Gemini sees the full cross-provider tool history
198
- msgs += [r2.message, user("How about tomorrow — will it rain?")]
199
- r3 = await stream(msgs, model='models/gemini-3-flash-preview', tools=tools, max_tokens=mtok, web_search_options={})
200
- ```
201
-
202
- Tomorrow in Paris (Saturday, June 13), the weather is expected to be mostly pleasant and sunny.
203
-
204
- While there is a small chance of light, passing showers (around a 20-25% chance), most forecasts indicate a dry day with mostly clear or scattered clouds. Temperatures will likely reach a comfortable high of around 22°C to 27°C (72°F to 80°F), making it a great day for being outdoors.
205
-
206
- So, while you might see a stray shower, you likely won't need to worry about heavy rain!
207
-
208
154
  ## Tool Choice
209
155
 
210
156
  Control whether the model must use tools, can’t use tools, or decides on its own:
211
157
 
212
158
  ``` python
213
159
  # Force the model to call a tool (even for a greeting)
214
- r = await stream([user("Hello there!")], model='claude-sonnet-4-20250514', tools=tools, tool_choice='required', max_tokens=mtok)
160
+ r = await stream([user("Hello there!")], model='claude-sonnet-5', tools=tools, tool_choice='required', max_tokens=mtok)
215
161
  print("Forced:", r.tool_calls)
216
162
 
217
163
  # Prevent tool use (model must answer directly)
218
164
  print("\nNo tools: ", end='')
219
- r = await stream([user("What's the weather?")], model='claude-sonnet-4-20250514', tools=tools, tool_choice='none', max_tokens=mtok)
165
+ r = await stream([user("What's the weather?")], model='claude-sonnet-5', tools=tools, tool_choice='none', max_tokens=mtok)
220
166
  ```
221
167
 
222
168
  - ⏳ `get_weather(city="<UNKNOWN>")` ⏳
@@ -327,11 +273,11 @@ long_ctx = "You are an expert on the solar system. " * 200
327
273
  system = Text(long_ctx, cache_control={'type': 'ephemeral'})
328
274
 
329
275
  print("Call 1: ", end='')
330
- r1 = await stream([user("What is Jupiter's mass?")], model='claude-sonnet-4-20250514', system=system, max_tokens=mtok)
276
+ r1 = await stream([user("What is Jupiter's mass?")], model='claude-sonnet-5', system=system, max_tokens=mtok)
331
277
  print(f"Usage: {r1.usage}")
332
278
 
333
279
  print("Call 2: ", end='')
334
- r2 = await stream([user("How about Saturn?")], model='claude-sonnet-4-20250514', system=system, max_tokens=mtok)
280
+ r2 = await stream([user("How about Saturn?")], model='claude-sonnet-5', system=system, max_tokens=mtok)
335
281
  print(f"Usage: {r2.usage}")
336
282
  ```
337
283
 
@@ -381,13 +327,12 @@ print(f"Usage: {r2.usage}")
381
327
  Send images to any provider that supports them. The canonical `input_image` part works everywhere:
382
328
 
383
329
  ``` 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
330
  img_msg = Msg(role='user', content=[
386
- InputImage(img_url),
331
+ InputImage(sample_img_url),
387
332
  Text("What do you see in this image?")
388
333
  ])
389
334
 
390
- for name, kw in [('claude-sonnet-4-20250514', {}), ('gpt-4o-mini', {}), ('models/gemini-3-flash-preview', {})]:
335
+ for name, kw in [('claude-sonnet-5', {}), ('gpt-4o-mini', {}), ('models/gemini-3-flash-preview', {})]:
391
336
  print(f"{name:>30s}: ", end='')
392
337
  r = await stream([img_msg], model=name, max_tokens=mtok, **kw)
393
338
  ```
@@ -0,0 +1,17 @@
1
+ fastllm/__init__.py,sha256=9Ir0J8R-z0_JnZDe2PsJohUmaNENqlehYHPTO3403g8,131
2
+ fastllm/_modidx.py,sha256=fQ6pg5H_5Pp8l7k12Fb5ca6uWmQewKtGtZBDi8hCuFg,44277
3
+ fastllm/acomplete.py,sha256=aP1ff9a7WKNhBd1kwKE2TJ-YyihLcdhd9-1Izi4wQP4,10772
4
+ fastllm/anthropic.py,sha256=k0QeuRnl_CalfCq74WXN6llRQqRiAQ9d8ohn_0YYC7A,18386
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=mFFaBpKNsq85sJjXIK5rYKb6WO7nlImEfowqDPf_yzA,19633
11
+ fastllm/streaming.py,sha256=i_FU_Mt-3F2lBEImYav8I5pEjUSKYk3JFIW3lkiXEb8,8813
12
+ fastllm/types.py,sha256=BIZHwlzsbkZELjAib2oSFRab8gKBa216DNNeE_P68go,19525
13
+ python_fastllm-0.0.51.dist-info/METADATA,sha256=Btmnr0P4mLlxL6J39LJEBg6Zw4KkTLu1-1o4ulP3RFY,18537
14
+ python_fastllm-0.0.51.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
15
+ python_fastllm-0.0.51.dist-info/entry_points.txt,sha256=WkWd04F3xVYFP5bsexaQe9Se6uelwE5ULoTtHWreO0s,133
16
+ python_fastllm-0.0.51.dist-info/top_level.txt,sha256=F8qodL7nEGUHGmzzqfhNKCTIr1i0D6cvudOnm3z7o0Y,8
17
+ python_fastllm-0.0.51.dist-info/RECORD,,
@@ -1,17 +0,0 @@
1
- fastllm/__init__.py,sha256=udUKGtrdGuO2q1CvwAXrPId3ToCsgVd8MI9FczPGC08,131
2
- fastllm/_modidx.py,sha256=UExnE14vuPnwPFZ1RcoK2iJawaLmlYJDsOKbpPx-F7o,42832
3
- fastllm/acomplete.py,sha256=aP1ff9a7WKNhBd1kwKE2TJ-YyihLcdhd9-1Izi4wQP4,10772
4
- fastllm/anthropic.py,sha256=RQpOpwIpXxpZe5httO7zgWautF7lJEPSzotcUP-eDVE,17265
5
- fastllm/chat.py,sha256=klji3SOYs5xyzrSFbNhy3uoQ2TSZ3a-8HTjvdRtaG5Q,25036
6
- fastllm/codex.py,sha256=r6auZ8egia83aGkWcF4132AzW4SsBvKeDwg_CxyXj9E,5808
7
- fastllm/gemini.py,sha256=d0nLkxPr5k3X6A8CZNaAuiRrWxmJrc6FfNylJoKNpIc,15291
8
- fastllm/openai_chat.py,sha256=mUOyos5bdmXukPRMD_sEDiVt4WXDRBzvYiCQskQUHPI,11769
9
- fastllm/openai_responses.py,sha256=pWg1rA53u5A0aTvnBczxvTK-aBIHm01U9FeIg3-lLbE,14618
10
- fastllm/responses.py,sha256=jcbKUwbEIsy7ngNcHUtv2bfYeDgqSysK_qWM-0LC9Mg,18767
11
- fastllm/streaming.py,sha256=8qVtWeZ7NlP-9rup_4Ho0ol2dx3-XuGcIMXpZ1GYdEo,9079
12
- fastllm/types.py,sha256=IYHA3Nfs2ghXUhgYy2bbbCAcfqU-guTOjI5gF3NW5NY,19260
13
- python_fastllm-0.0.49.dist-info/METADATA,sha256=VYlgLNW8x53M1VvmBcW7qyq3-A6FF5a7Twh_z8-q3l0,21143
14
- python_fastllm-0.0.49.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
15
- python_fastllm-0.0.49.dist-info/entry_points.txt,sha256=WkWd04F3xVYFP5bsexaQe9Se6uelwE5ULoTtHWreO0s,133
16
- python_fastllm-0.0.49.dist-info/top_level.txt,sha256=F8qodL7nEGUHGmzzqfhNKCTIr1i0D6cvudOnm3z7o0Y,8
17
- python_fastllm-0.0.49.dist-info/RECORD,,