python-fastllm 0.0.59__py3-none-any.whl → 0.0.60__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.59"
5
+ __version__ = "0.0.60"
fastllm/_modidx.py CHANGED
@@ -22,6 +22,8 @@ d = { 'settings': { 'branch': 'main',
22
22
  'fastllm.anthropic': { 'fastllm.anthropic._add_cache_control': ('anthropic.html#_add_cache_control', 'fastllm/anthropic.py'),
23
23
  'fastllm.anthropic._ant_cc': ('anthropic.html#_ant_cc', 'fastllm/anthropic.py'),
24
24
  'fastllm.anthropic._ant_part_type': ('anthropic.html#_ant_part_type', 'fastllm/anthropic.py'),
25
+ 'fastllm.anthropic._clear_cache_control': ( 'anthropic.html#_clear_cache_control',
26
+ 'fastllm/anthropic.py'),
25
27
  'fastllm.anthropic._sanid': ('anthropic.html#_sanid', 'fastllm/anthropic.py'),
26
28
  'fastllm.anthropic._url_cite': ('anthropic.html#_url_cite', 'fastllm/anthropic.py'),
27
29
  'fastllm.anthropic.acollect_stream': ('anthropic.html#acollect_stream', 'fastllm/anthropic.py'),
@@ -140,7 +142,8 @@ d = { 'settings': { 'branch': 'main',
140
142
  'fastllm.codex.main': ('codex.html#main', 'fastllm/codex.py'),
141
143
  'fastllm.codex.mk_jwt': ('codex.html#mk_jwt', 'fastllm/codex.py'),
142
144
  'fastllm.codex.token_expired': ('codex.html#token_expired', 'fastllm/codex.py')},
143
- 'fastllm.gemini': { 'fastllm.gemini._fn_calls': ('gemini.html#_fn_calls', 'fastllm/gemini.py'),
145
+ 'fastllm.gemini': { 'fastllm.gemini._fn_call': ('gemini.html#_fn_call', 'fastllm/gemini.py'),
146
+ 'fastllm.gemini._fn_calls': ('gemini.html#_fn_calls', 'fastllm/gemini.py'),
144
147
  'fastllm.gemini._gem_filter_sch': ('gemini.html#_gem_filter_sch', 'fastllm/gemini.py'),
145
148
  'fastllm.gemini._gem_part_type': ('gemini.html#_gem_part_type', 'fastllm/gemini.py'),
146
149
  'fastllm.gemini._url_cite': ('gemini.html#_url_cite', 'fastllm/gemini.py'),
@@ -315,7 +318,9 @@ d = { 'settings': { 'branch': 'main',
315
318
  'fastllm.responses._msg_parts': ('responses.html#_msg_parts', 'fastllm/responses.py'),
316
319
  'fastllm.responses._new_id': ('responses.html#_new_id', 'fastllm/responses.py'),
317
320
  'fastllm.responses._response_base': ('responses.html#_response_base', 'fastllm/responses.py'),
321
+ 'fastllm.responses._server_call': ('responses.html#_server_call', 'fastllm/responses.py'),
318
322
  'fastllm.responses._text_content': ('responses.html#_text_content', 'fastllm/responses.py'),
323
+ 'fastllm.responses._visible_parts': ('responses.html#_visible_parts', 'fastllm/responses.py'),
319
324
  'fastllm.responses.normalize_call_ids': ('responses.html#normalize_call_ids', 'fastllm/responses.py'),
320
325
  'fastllm.responses.pending_response': ('responses.html#pending_response', 'fastllm/responses.py'),
321
326
  'fastllm.responses.response_event': ('responses.html#response_event', 'fastllm/responses.py'),
fastllm/acomplete.py CHANGED
@@ -1,4 +1,4 @@
1
- """A high level unified function make api calls"""
1
+ """A unified async API for model completions and streaming"""
2
2
 
3
3
  # AUTOGENERATED! DO NOT EDIT! File to edit: ../nbs/06_acomplete.ipynb.
4
4
 
fastllm/anthropic.py CHANGED
@@ -1,3 +1,5 @@
1
+ """Translate Anthropic Messages API requests, responses, and streams to and from fastllm's shared message types, with tool replay, media, and prompt caching"""
2
+
1
3
  # AUTOGENERATED! DO NOT EDIT! File to edit: ../nbs/04_anthropic.ipynb.
2
4
 
3
5
  # %% auto #0
@@ -16,7 +18,7 @@ from fasttransport.errors import api_error_from_event
16
18
 
17
19
  from .types import *
18
20
  from aidialog.msg_parts import (MdStr, Part, PartType, mk_part, Msg, Completion, Text, Thinking, ToolUse, ToolResult, tool_text,
19
- InputImage, InputAudio, InputVideo, InputFile, data_url)
21
+ InputImage, InputAudio, InputVideo, InputFile, data_url)
20
22
  from .streaming import *
21
23
  from .streaming import mk_acollect_stream
22
24
 
@@ -44,8 +46,8 @@ def norm_usage(resp):
44
46
  pt = int(usg.get("input_tokens", 0) or 0) + cached + cache_creation
45
47
  ct = int(usg.get("output_tokens", 0) or 0)
46
48
  return Usage(prompt_tokens=pt, completion_tokens=ct, total_tokens=pt + ct,
47
- cached_tokens=cached, cache_creation_tokens=cache_creation, reasoning_tokens=0, raw=usg,
48
- search_queries=int(nested_idx(usg, 'server_tool_use', 'web_search_requests') or 0))
49
+ cached_tokens=cached, cache_creation_tokens=cache_creation, reasoning_tokens=0, raw=usg,
50
+ search_queries=int(nested_idx(usg, 'server_tool_use', 'web_search_requests') or 0))
49
51
 
50
52
  def finalize_usage(usg, parts):
51
53
  "Split reasoning tokens out of `output_tokens`: the reported `thinking_tokens`, else an estimate from the thinking text."
@@ -110,7 +112,9 @@ def norm_tr_parts(content):
110
112
  if typ=='text': parts.append(Text(b.get('text',''), raw=b))
111
113
  elif typ=='image':
112
114
  s = b['source']
113
- parts.append(InputImage(f"data:{s['media_type']};base64,{s['data']}", mime=s['media_type'], raw=b))
115
+ if s['type']=='url': parts.append(InputImage(s['url'], raw=b))
116
+ elif s['type']=='base64': parts.append(InputImage(f"data:{s['media_type']};base64,{s['data']}", mime=s['media_type'], raw=b))
117
+ else: raise ValueError(f"Unsupported Anthropic image source type: {s['type']!r}")
114
118
  else: parts.append(mk_part(**b))
115
119
  return parts
116
120
 
@@ -238,13 +242,11 @@ def denorm_reasoning(v):
238
242
  # %% ../nbs/04_anthropic.ipynb #8fa9fbb8
239
243
  def denorm_web_search(v):
240
244
  "Map canonical web_search_options to Anthropic hosted web_search tool."
241
- _max_uses = {"low": 1, "medium": 5, "high": 10}
245
+ _max_uses = dict(low=1, medium=5, high=10)
242
246
  t = {"type": "web_search_20250305", "name": "web_search"}
243
247
  if (typ := (v or {}).get("type")): t["type"] = typ
244
- if (s := (v or {}).get("search_context_size")):
245
- t["max_uses"] = _max_uses.get(s, 5)
246
- if (u := (v or {}).get("user_location", {}).get("approximate")):
247
- t["user_location"] = {"type": "approximate", **u}
248
+ if (s := (v or {}).get("search_context_size")): t["max_uses"] = _max_uses.get(s, 5)
249
+ if (u := (v or {}).get("user_location", {}).get("approximate")): t["user_location"] = {"type": "approximate", **u}
248
250
  return t
249
251
 
250
252
  # %% ../nbs/04_anthropic.ipynb #6b485275
@@ -269,12 +271,12 @@ def denorm_user(m:Msg):
269
271
  def denorm_image(p, max_sz=None):
270
272
  if (b64:=data_url(p.text)):
271
273
  data = resize_b64(b64[1], max_sz) if max_sz else b64[1]
272
- return {"type": "image", "source": {"type": "base64", "media_type": b64[0], "data": data}}
274
+ return {"type": "image", "source": dict(type="base64", media_type=b64[0], data=data)}
273
275
  return {"type": "image", "source": {"type": "url", "url": p.text}}
274
276
 
275
277
  # %% ../nbs/04_anthropic.ipynb #fc6bbdfc
276
278
  def denorm_file(p):
277
- if (b64:=data_url(p.text)): return {"type": "document", "source": {"type": "base64", "media_type": b64[0], "data": b64[1]}}
279
+ if (b64:=data_url(p.text)): return {"type": "document", "source": dict(type="base64", media_type=b64[0], data=b64[1])}
278
280
  return {"type": "document", "source": {"type": "url", "url": p.text}}
279
281
 
280
282
  # %% ../nbs/04_anthropic.ipynb #37b63cc2
@@ -303,15 +305,21 @@ def _add_cache_control(
303
305
  return msg
304
306
 
305
307
  # %% ../nbs/04_anthropic.ipynb #6bc52ef5
308
+ def _clear_cache_control(obj):
309
+ "Clear canonical cache marks from a message or part."
310
+ if isinstance(obj, Msg):
311
+ for p in obj.content: _clear_cache_control(p)
312
+ elif isinstance(obj, Part): obj.cache_control = None
313
+
306
314
  def apply_cache_idxs(
307
315
  msgs, # Canonical Msgs to mark
308
316
  cache_idxs=[-1], # Breakpoint idxs into `msgs` (tool-role msgs excluded); 0 is `sp` when given
309
317
  sp=None, # System prompt: str, Msg, or Part
310
318
  ttl=None # Cache TTL: '5m' (default) or '1h'
311
319
  ):
312
- "Strip all cache breakpoints from `msgs`, then mark `cache_idxs`; returns `sp`, marked if 0 is included"
313
- for m in msgs:
314
- for p in m.content: p.cache_control = None
320
+ "Replace cache breakpoints in `msgs` and `sp`, then return the system prompt."
321
+ for m in msgs: _clear_cache_control(m)
322
+ _clear_cache_control(sp)
315
323
  if sp:
316
324
  if 0 in cache_idxs:
317
325
  if isinstance(sp, str): sp = Text(sp)
@@ -335,10 +343,8 @@ def mk_payload(msgs, model, **kwargs):
335
343
  if tools:=kwargs.get('tools'): payload['tools'] = denorm_tool_schs(tools)
336
344
  if tchc:=kwargs.get('tool_choice'): payload['tool_choice'] = denorm_tool_choice(tchc)
337
345
  if thk:=kwargs.get('reasoning_effort'): payload.update(denorm_reasoning(thk))
338
- if (wopts:=kwargs.get('web_search_options')) is not None:
339
- payload.setdefault('tools', []).append(denorm_web_search(wopts))
340
- if (temp:=kwargs.get('temperature')) is not None:
341
- payload['temperature'] = temp
346
+ if (wopts:=kwargs.get('web_search_options')) is not None: payload.setdefault('tools', []).append(denorm_web_search(wopts))
347
+ if (temp:=kwargs.get('temperature')) is not None: payload['temperature'] = temp
342
348
  return payload
343
349
 
344
350
  # %% ../nbs/04_anthropic.ipynb #60d52c1f
@@ -367,5 +373,4 @@ def cost(usage, m):
367
373
  # %% ../nbs/04_anthropic.ipynb #f7c0b989
368
374
  api_registry.register('anthropic', norm_tool_calls=norm_tool_calls, norm_parts=norm_parts, norm_finish=norm_finish, norm_usage=norm_usage,
369
375
  finalize_usage=finalize_usage, raw_msg=raw_msg, collate_raw=collate_raw, acollect_stream=acollect_stream, mk_payload=mk_payload,
370
- cost=cost, get_hdrs=get_hdrs,
371
- endpoint='/v1/messages')
376
+ cost=cost, get_hdrs=get_hdrs, endpoint='/v1/messages')
fastllm/chat.py CHANGED
@@ -1,4 +1,4 @@
1
- """High level chat api for fastllm similar to lisette"""
1
+ """A high-level chat API for fastllm, similar to Lisette"""
2
2
 
3
3
  # AUTOGENERATED! DO NOT EDIT! File to edit: ../nbs/07_chat.ipynb.
4
4
 
@@ -112,9 +112,12 @@ _cwe_msg = "ContextWindowExceededError: Do no more tool calls and complete your
112
112
 
113
113
  # %% ../nbs/07_chat.ipynb #05c20a94
114
114
  def search_count(r):
115
- if cnt := nested_idx(r.usage.raw, 'server_tool_use', 'web_search_requests'): return cnt # Anthropic
116
- if cnt := nested_idx(r.usage.raw, 'server_tool_use', 'google_search'): return cnt # Gemini
117
- if cnt := nested_idx(r.usage.raw, 'web_search_requests'): return cnt # streaming with `include_usage`
115
+ raw = r.usage.raw or {}
116
+ if (cnt := raw.get('web_search_requests')) is not None: return cnt # host aggregate
117
+ if cnt := r.usage.search_queries: return cnt
118
+ legacy = raw.get('server_tool_use') or {}
119
+ if (cnt := legacy.get('web_search_requests')) is not None: return cnt # Anthropic
120
+ if (cnt := legacy.get('google_search')) is not None: return cnt # Gemini
118
121
  return 0
119
122
 
120
123
  # %% ../nbs/07_chat.ipynb #61395e0d
@@ -288,8 +291,9 @@ async def _call(self:AsyncChat, msg=None, step=1, search=None, tool_choice=None,
288
291
  async for o in self._call_cbs('after_msgs'): yield o
289
292
 
290
293
  self.turn_kwargs, self.stream = kwargs, t.stream
291
- kw = merge(kwargs, dict(xtra_body=merge(kwargs.get('xtra_body'), initial_body))) if initial_body else kwargs
292
294
  async for o in self._call_cbs('before_acomplete'): yield o
295
+ kw = self.turn_kwargs
296
+ if initial_body: kw = merge(kw, dict(xtra_body=merge(kw.get('xtra_body'), initial_body)))
293
297
  res = await acomplete(self.turn_msgs, self.model, system=self.turn_sysp, stream=t.stream, tools=self.tool_schemas,
294
298
  tool_choice=tool_choice, max_tokens=int(max_tokens), temperature=None if t.think else ifnone(t.temp,self.temp),
295
299
  previous_response_id=self.response_id if self.use_previous_response_id else None,
@@ -413,7 +417,7 @@ class StopReasonCallback(ChatCallback):
413
417
  action,msg = _handle_stop_reason(self.turn_res)
414
418
  if action != 'warning': return
415
419
  add_warning(self.chat.turn_res, msg)
416
- if self.stream: yield dict(text=f"warning: {msg}\n\n")
420
+ if self.stream: yield Text(f'\n\nwarning: {msg}\n\n')
417
421
 
418
422
  # %% ../nbs/07_chat.ipynb #aa7630b2
419
423
  _lang2tool = dict(py='python', bash='bash')
@@ -493,11 +497,11 @@ defaults.chat_callbacks = [FenceToolCallback, ToolReminderCallback, StopReasonCa
493
497
 
494
498
  # %% ../nbs/07_chat.ipynb #ead4b56d
495
499
  class StreamAccum:
496
- "Accumulate a formatted stream: append each item's `formatted`, swap in `chat.full()` on `Refresh`"
500
+ "Accumulate formatted parts; refresh from `chat.full()` on `Refresh` or `Completion`"
497
501
  def __init__(self, chat=None, mx=2000): self.chat,self.mx,self.txt,self.last = chat,mx,'',None
498
502
  def __call__(self, o):
499
503
  "Feed one stream item; True if the display text changed"
500
- if isinstance(o, Refresh):
504
+ if isinstance(o, (Refresh, Completion)):
501
505
  if self.chat is None: return False
502
506
  self.txt,self.last = self.chat.full(mx=self.mx)+'\n\n',None
503
507
  elif f := getattr(o, 'formatted', ''):
@@ -509,7 +513,7 @@ class StreamAccum:
509
513
 
510
514
  # %% ../nbs/07_chat.ipynb #944bcd25
511
515
  async def adisplay_stream(rs, chat=None, mx=2000):
512
- "Use IPython.display to markdown display the response stream, refreshing from `chat.full()` on `Refresh`."
516
+ "Display a stream as Markdown, refreshing from `chat.full()` on `Refresh` or `Completion`."
513
517
  try: from IPython.display import display, Markdown
514
518
  except ModuleNotFoundError: raise ModuleNotFoundError("This function requires ipython. Please run `pip install ipython` to use.")
515
519
  acc,h = StreamAccum(chat, mx=mx), display(Markdown(' '), display_id=True)
fastllm/gemini.py CHANGED
@@ -1,3 +1,5 @@
1
+ """Translate Gemini generateContent requests, responses, and streams to and from fastllm's shared message types, including tools, thought signatures, search grounding, and media"""
2
+
1
3
  # AUTOGENERATED! DO NOT EDIT! File to edit: ../nbs/05_gemini.ipynb.
2
4
 
3
5
  # %% auto #0
@@ -16,7 +18,7 @@ from fasttransport.errors import api_error_from_event
16
18
 
17
19
  from .types import *
18
20
  from aidialog.msg_parts import (MdStr, Part, PartType, Msg, Completion, Text, Thinking, ToolUse, ToolResult, tool_text,
19
- InputImage, InputAudio, InputVideo, InputFile, sys_text, part_txt, data_url, url_mime)
21
+ InputImage, InputAudio, InputVideo, InputFile, sys_text, part_txt, data_url, url_mime)
20
22
  from .streaming import *
21
23
  from .streaming import mk_acollect_stream
22
24
 
@@ -41,9 +43,12 @@ def grounding(cand):
41
43
  return cites, call
42
44
 
43
45
  # %% ../nbs/05_gemini.ipynb #48de3125
44
- def _fn_calls(parts):
45
- return [ToolUse(id=fc.get("id", f"call_{i}"), name=fc.get("name", ""), arguments=fc.get("args") or {}, raw={k:v for k,v in p.items() if k != "functionCall"})
46
- for i,p in enumerate(parts) if (fc := p.get("functionCall"))]
46
+ def _fn_call(p, i):
47
+ fc = p['functionCall']
48
+ return ToolUse(id=fc.get("id", f"call_{i}"), name=fc.get("name", ""), arguments=fc.get("args") or {},
49
+ raw={k:v for k,v in p.items() if k != "functionCall"})
50
+
51
+ def _fn_calls(parts): return [_fn_call(p, i) for i,p in enumerate(parts) if p.get('functionCall')]
47
52
 
48
53
  def norm_tool_calls(resp):
49
54
  "Tool calls of a response: the `google_search` call its grounding describes, then its `functionCall` parts"
@@ -67,7 +72,7 @@ def norm_usage(resp):
67
72
  if call := grounding(cand)[1]: stu['google_search'] = len(call.arguments['queries'] or [1])
68
73
  if stu: usg['server_tool_use'] = stu
69
74
  return Usage(prompt_tokens=pt, completion_tokens=ct, total_tokens=tt,
70
- cached_tokens=cached, reasoning_tokens=reasoning, search_queries=stu.get('google_search', 0), raw=usg)
75
+ cached_tokens=cached, reasoning_tokens=reasoning, search_queries=stu.get('google_search', 0), raw=usg)
71
76
 
72
77
  # %% ../nbs/05_gemini.ipynb #7a8b1f8f
73
78
  def norm_finish(resp, tcs=None):
@@ -90,13 +95,11 @@ def norm_parts(resp):
90
95
  "Normalize Gemini generateContent response; a search's `toolCall` and `toolResponse` parts give way to the call `grounding` builds"
91
96
  c0 = nested_idx(resp, 'candidates', 0) or {}
92
97
  cites,call = grounding(c0)
93
- tc_map = {tc.id: tc for tc in norm_tool_calls(resp)}
94
98
  parts = [call] if call else []
95
- for p in nested_idx(c0, 'content', 'parts') or []:
99
+ for i,p in enumerate(nested_idx(c0, 'content', 'parts') or []):
96
100
  if 'toolCall' in p or 'toolResponse' in p: continue
97
101
  typ = _gem_part_type(p)
98
- if typ == PartType.tool_use:
99
- if tc := tc_map.get(p['functionCall'].get('id')): parts.append(tc)
102
+ if typ == PartType.tool_use: parts.append(_fn_call(p, i))
100
103
  elif typ == PartType.thinking: parts.append(Thinking(p.get("text",""), raw=p))
101
104
  elif typ == PartType.tool_result: parts.append(ToolResult(text=p.get("text",""), raw=p))
102
105
  else: parts.append(Text(p.get("text",""), raw=p))
@@ -175,15 +178,20 @@ def denorm_msgs(msgs:list[Msg]):
175
178
  return res
176
179
 
177
180
  # %% ../nbs/05_gemini.ipynb #adb525db
178
- _valid_gemini_sch = {'type', 'format', 'title', 'description', 'nullable', 'default',
179
- 'items', 'minItems', 'maxItems', 'enum', 'properties', 'propertyOrdering',
180
- 'required', 'minProperties', 'maxProperties', 'minimum', 'maximum',
181
- 'minLength', 'maxLength', 'pattern', 'example', 'anyOf'}
181
+ _valid_gemini_sch = {'type', 'format', 'title', 'description', 'nullable', 'default', 'items', 'minItems', 'maxItems', 'enum', 'properties',
182
+ 'propertyOrdering', 'required', 'minProperties', 'maxProperties', 'minimum', 'maximum', 'minLength', 'maxLength', 'pattern', 'example',
183
+ 'anyOf'}
182
184
 
183
185
  def _gem_filter_sch(s):
184
186
  if isinstance(s, list): return [_gem_filter_sch(x) for x in s]
185
187
  if not isinstance(s, dict): return s
186
- return {k: _gem_filter_sch(v) for k,v in s.items() if k in _valid_gemini_sch}
188
+ res = {}
189
+ for k,v in s.items():
190
+ if k not in _valid_gemini_sch: continue
191
+ if k == 'properties': res[k] = {name:_gem_filter_sch(prop) for name,prop in v.items()}
192
+ elif k in ('items', 'anyOf'): res[k] = _gem_filter_sch(v)
193
+ else: res[k] = v
194
+ return res
187
195
 
188
196
  def denorm_tool_schs(tools):
189
197
  "Convert canonical tools to Gemini format."
@@ -192,8 +200,7 @@ def denorm_tool_schs(tools):
192
200
  fn = fn_schema(t)
193
201
  if fn is None: other.append(t); continue
194
202
  name, desc, params = fn
195
- params = {k:v for k,v in params.items() if k in _valid_gemini_sch}
196
- params['properties'] = {k:_gem_filter_sch(v) for k,v in params['properties'].items()}
203
+ params = _gem_filter_sch(params)
197
204
  fn_decls.append(dict(name=name, description=desc, parameters=params))
198
205
  out = other[:]
199
206
  if fn_decls: out.insert(0, dict(functionDeclarations=fn_decls))
@@ -214,13 +221,11 @@ _gem_think_levels = dict(minimal='low', low='low', medium='medium', high='high'
214
221
 
215
222
  def denorm_reasoning(v, model=''):
216
223
  "Map canonical reasoning_effort to Gemini thinkingConfig (uses thinkingLevel for Gemini 3+)."
217
- err = ValueError(f"Invalid reasoning effort for Gemini: {v}, accepted string values are: {list(_gem_think_budgets)} and dicts are passthrough")
218
- if v is None: return None
219
- elif isinstance(v, dict): return v
220
- elif isinstance(v, str) and v in _gem_think_budgets:
221
- # defaults to includeThoughts same as litellm
222
- if 'gemini-3' in model: return {'thinkingLevel': _gem_think_levels.get(v, 'medium'), 'includeThoughts': True}
223
- return {'thinkingBudget': _gem_think_budgets.get(str(v).lower(), 1024), 'includeThoughts': True}
224
+ if v is None or isinstance(v, dict): return v
225
+ if not isinstance(v, str) or v not in _gem_think_budgets:
226
+ raise ValueError(f"Invalid reasoning effort for Gemini: {v!r}; expected one of {list(_gem_think_budgets)}, a dict, or None")
227
+ if 'gemini-3' in model: return {'thinkingLevel': _gem_think_levels[v], 'includeThoughts': True}
228
+ return {'thinkingBudget': _gem_think_budgets[v], 'includeThoughts': True}
224
229
 
225
230
  # %% ../nbs/05_gemini.ipynb #8fa9fbb8
226
231
  def denorm_web_search(v): return {"googleSearch": {}}
@@ -325,15 +330,7 @@ def cost(usage, m):
325
330
  return cost
326
331
 
327
332
  # %% ../nbs/05_gemini.ipynb #f7c0b989
328
- api_ns = dict(norm_tool_calls=norm_tool_calls,
329
- norm_parts=norm_parts,
330
- norm_finish=norm_finish,
331
- norm_usage=norm_usage,
332
- acollect_stream=acollect_stream,
333
- raw_msg=raw_msg,
334
- collate_raw=collate_raw,
335
- mk_payload=mk_payload,
336
- cost=cost,
337
- get_hdrs=get_hdrs,
338
- endpoint=endpoint)
333
+ api_ns = dict(norm_tool_calls=norm_tool_calls, norm_parts=norm_parts, norm_finish=norm_finish, norm_usage=norm_usage,
334
+ acollect_stream=acollect_stream, raw_msg=raw_msg, collate_raw=collate_raw, mk_payload=mk_payload, cost=cost, get_hdrs=get_hdrs,
335
+ endpoint=endpoint)
339
336
  api_registry.register('gemini', **api_ns)
fastllm/openai_chat.py CHANGED
@@ -1,3 +1,5 @@
1
+ """Translate Chat Completions requests, responses, and streams to and from fastllm's shared message types"""
2
+
1
3
  # AUTOGENERATED! DO NOT EDIT! File to edit: ../nbs/03_oai_chat.ipynb.
2
4
 
3
5
  # %% auto #0
@@ -1,3 +1,5 @@
1
+ """Translate Responses API payloads, completed responses, and stream events to and from fastllm's shared message types"""
2
+
1
3
  # AUTOGENERATED! DO NOT EDIT! File to edit: ../nbs/02_oai_responses.ipynb.
2
4
 
3
5
  # %% auto #0
@@ -65,7 +67,7 @@ def norm_finish(resp, tcs=None):
65
67
 
66
68
  # %% ../nbs/02_oai_responses.ipynb #bbfb99b9
67
69
  def search_hits(parts):
68
- "OpenAI reports no result for a hosted search, so a server call's empty `text` becomes the message's cited sources, one `title: url` line each"
70
+ "Fill empty server-tool results with the message's cited sources as `title: url` lines"
69
71
  hits = {c['url']: c.get('title', '') for p in parts if isinstance(p, Text) for c in p.citations or [] if c.get('url')}
70
72
  for p in parts:
71
73
  if isinstance(p, ToolUse) and p.server and not p.text: p.text = MdStr('\n'.join(f"{t}: {u}" for u,t in hits.items()))
@@ -77,9 +79,7 @@ def norm_parts(resp):
77
79
  for item in resp["output"]:
78
80
  if (typ:=item["type"]) == "message":
79
81
  for c in item["content"]:
80
- if (ctyp := c["type"]) == "output_text":
81
- c['citations'] = c.pop('annotations', [])
82
- parts.append(Text(c['text'], citations=c['citations'], raw=c))
82
+ if (ctyp := c["type"]) == "output_text": parts.append(Text(c['text'], citations=list(c.get('annotations') or []), raw=c))
83
83
  elif ctyp == "refusal": parts.append(Refusal(c['refusal'], raw=c))
84
84
  elif typ == "reasoning":
85
85
  for s in item["summary"]: parts.append(Thinking(s['text'], raw=item))
@@ -88,7 +88,7 @@ def norm_parts(resp):
88
88
 
89
89
  # %% ../nbs/02_oai_responses.ipynb #2aaab962
90
90
  def raw_msg(resp):
91
- "The output items as the API sent them"
91
+ "Return the response output list for assistant replay, without copying"
92
92
  return resp['output']
93
93
 
94
94
  # %% ../nbs/02_oai_responses.ipynb #ef2d19f3
@@ -290,6 +290,5 @@ def fix_payload(payload, model, vendor_name):
290
290
  # %% ../nbs/02_oai_responses.ipynb #07114b55
291
291
  api_ns = dict(norm_tool_calls=norm_tool_calls, norm_parts=norm_parts, norm_finish=norm_finish, norm_usage=norm_usage,
292
292
  supports_previous_response_id=True, raw_msg=raw_msg, collate_raw=collate_raw, acollect_stream=acollect_stream, mk_payload=mk_payload,
293
- cost=cost, get_hdrs=get_hdrs, fix_payload=fix_payload,
294
- endpoint='/responses')
293
+ cost=cost, get_hdrs=get_hdrs, fix_payload=fix_payload, endpoint='/responses')
295
294
  api_registry.register('openai', **api_ns)
fastllm/responses.py CHANGED
@@ -1,12 +1,12 @@
1
1
  """Provider-independent Responses resources and streaming over FastLLM
2
2
 
3
- `response_input` converts a plain prompt or standard input-item array to canonical messages. System and developer messages become provider instructions; adjacent function outputs remain one tool message so a parallel batch reaches the next model turn together.
3
+ `response_input` accepts a prompt string or an input-item array. It returns `(messages, instructions)`. System and developer text goes in the instructions; the remaining items become shared messages.
4
4
 
5
- FastLLM completions become ordinary assistant-message and function-call items. Call ids are normalized before the canonical history is retained, so a later tool result always refers to the same id the client saw.
5
+ `normalize_call_ids` supplies missing client call IDs before we retain the history. Later tool results can refer to the same IDs that the client received.
6
6
 
7
- `ResponseState` is the complete provider-independent continuation value. A host may store it under any ownership and expiry policy. When a provider id exists, the next turn sends only new items; otherwise it replays canonical history.
7
+ `ResponseState` holds the public ID, model, full message history, and optional provider response ID. The host can store it under its own ownership and expiry rules. A provider ID lets the next call send just the new messages; without one, it needs the retained history.
8
8
 
9
- Every request is streamed. `_ResponseStream` only tracks the standard event grammar for one inference; it contains no provider, persistence, or billing policy."""
9
+ FastLLM calls stream internally. The helpers here turn their parts into Responses SSE frames; they don't decide storage, ownership, or billing policy."""
10
10
 
11
11
  # AUTOGENERATED! DO NOT EDIT! File to edit: ../nbs/06a_responses.ipynb.
12
12
 
@@ -361,13 +361,25 @@ def done_events(self:_ResponseStream, comp):
361
361
  if isinstance(part, ToolUse) and part.server:
362
362
  events.append(self.event('response.output_item.done', output_index=self.next_index, item=server_call_item(part)))
363
363
  self.next_index += 1
364
- for i,tc in enumerate(comp.tool_calls):
364
+ client_calls = [tc for tc in comp.tool_calls if not tc.server]
365
+ for i,tc in enumerate(client_calls):
365
366
  if not tc.id and i < len(self.streamed_call_ids): tc.id = self.streamed_call_ids[i]
366
367
  tc._response_item_id = self.tool_item_ids.get(tc.id)
367
368
  for part in comp.message.content:
368
369
  if isinstance(part, Text): part._response_item_id = self.message_id
369
370
  return events
370
371
 
372
+ # %% ../nbs/06a_responses.ipynb #04a20345
373
+ def _server_call(result):
374
+ "A public server call with the host's completed result."
375
+ return ToolUse(id=result.id, name=result.name, arguments=result.arguments, server=True, text=result.text)
376
+
377
+ def _visible_parts(parts, results):
378
+ "Replace completed host calls without changing the provider's parts."
379
+ completed = {r.id:r for r in results}
380
+ return [_server_call(completed[p.id]) if isinstance(p, ToolUse) and p.id in completed else p for p in parts]
381
+
382
+
371
383
  # %% ../nbs/06a_responses.ipynb #e10c928b
372
384
  @patch
373
385
  async def events(self:AsyncResponses, turn, finalize=None, stream=None):
@@ -385,11 +397,11 @@ async def events(self:AsyncResponses, turn, finalize=None, stream=None):
385
397
  results = await self.server_results(turn, comp)
386
398
  if not results or len(results) < len(comp.tool_calls): break
387
399
  texts += [p for p in comp.message.content if isinstance(p, Text) or (isinstance(p, ToolUse) and p.server)]
388
- texts += [ToolUse(id=r.id, name=r.name, arguments=r.arguments, server=True, text=r.text) for r in results]
400
+ texts += [_server_call(r) for r in results]
389
401
  turn = self.server_turn(turn, comp, results)
390
402
  next_state = self.state(turn, comp, results)
391
403
  comp = copy.copy(comp)
392
- comp.message,comp.usage = Msg('assistant', texts + comp.message.content),usage
404
+ comp.message,comp.usage = Msg('assistant', texts + _visible_parts(comp.message.content, results)),usage
393
405
  for event in state.done_events(comp): yield event
394
406
  response = response_object(first.id, first.body, comp, first.previous.id if first.previous else None, first.created_at)
395
407
  if finalize and (extra := await finalize(next_state, comp)): response['usage'].update(extra)
fastllm/streaming.py CHANGED
@@ -184,7 +184,6 @@ async def mk_acollect_stream(it, index_fn, model=None, api_name=None, vendor_nam
184
184
  if stop: fin = FinishReason.stop
185
185
  fin = FinishReason.tool_calls if fin==FinishReason.stop and any(~L(tcs).attrgot('server')) else fin # recheck tool calls post collation
186
186
  # tool calls and non-anthropic citations are yielded at the end
187
- yield Completion(model,
188
- message=Msg(role="assistant", content=parts, raw=api_registry[api_name].collate_raw(deltas) if api_name else None),
189
- finish_reason=fin, usage=usg, api_name=api_name, vendor_name=vendor_name,
190
- raw={'deltas':deltas})
187
+ (((((((yield Completion(model,
188
+ message=Msg(role='assistant', content=parts, raw=api_registry[api_name].collate_raw(deltas) if api_name else None),
189
+ finish_reason=fin, usage=usg, api_name=api_name, vendor_name=vendor_name, raw={'deltas': deltas}))))))))
fastllm/types.py CHANGED
@@ -136,11 +136,11 @@ _typed_re = re.compile(r'^𝍁(\w+)𝍁(.+)$', re.S)
136
136
  _typed_strs = {c.__name__: c for c in (StopResponse, FullResponse)}
137
137
 
138
138
  def wrap_typed(s):
139
- "Mark `s` with a `𝍁TypeName𝍁` prefix when it is a `str` subclass, so the type survives a plain-string wire"
139
+ "Prefix a `str` subclass with `𝍁TypeName𝍁` for transmission as plain text"
140
140
  return s if type(s) is str else f'𝍁{type(s).__name__}𝍁{s}'
141
141
 
142
142
  def unwrap_typed(s):
143
- "Rebuild the named `str` subclass from a `𝍁`-marked string; unknown names get a minted ephemeral type"
143
+ "Decode a `𝍁TypeName𝍁` prefix, creating a `str` subclass for an unknown name"
144
144
  if not (m := _typed_re.match(s)): return s
145
145
  nm,body = m.groups()
146
146
  return _typed_strs.setdefault(nm, type(nm, (str,), {}))(body)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-fastllm
3
- Version: 0.0.59
3
+ Version: 0.0.60
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
@@ -0,0 +1,18 @@
1
+ fastllm/__init__.py,sha256=RzCM0b2ATYsYmXlX_uPf0CguuCCIUz7Jl-gj65TszzE,131
2
+ fastllm/_modidx.py,sha256=iyQLdOYLOPp87AmgdFQhL7QUxXMKh-TooHd6swPFuMc,46958
3
+ fastllm/acomplete.py,sha256=2BTlr5ZZFslscSCquXes3OnwrzCmtbPxGCwZtLGdPkY,10788
4
+ fastllm/anthropic.py,sha256=N8gfoDftlH-WFVO3CED4OGTv5qDTDGBHzp4JevgOFss,19347
5
+ fastllm/chat.py,sha256=f92AOIpJipEi3GZPnTKinJvxMU2RLdu_qEKkAUjgtcU,25099
6
+ fastllm/codex.py,sha256=r6auZ8egia83aGkWcF4132AzW4SsBvKeDwg_CxyXj9E,5808
7
+ fastllm/gemini.py,sha256=56C3NJt8WLwx_XUWU-AElyfz_V5Zp2hIGmmkFhjFWn0,17527
8
+ fastllm/model_prices.json,sha256=pa0j96LZgklYii8howX443QbukUP1iFr3lBcucS-6Yo,2133018
9
+ fastllm/openai_chat.py,sha256=yGtUQbN8UBzMwDkBUALLWdlxYOcHWX78ZT2Ic8FCXWg,12489
10
+ fastllm/openai_responses.py,sha256=n0uzAULex61rXNWuzvxSBV9-qHpqSQfl-7KBNk_GO6w,15440
11
+ fastllm/responses.py,sha256=k5D3YS8zgvUBpk_qADPtvVD9CUOWl4iej_0qvi-GuVs,23166
12
+ fastllm/streaming.py,sha256=dQ7ZFc4JEtPanoKXizww91rmYXTODTisQJb1FlSfWMU,8765
13
+ fastllm/types.py,sha256=dWFeU3UGA1Z6y5Sn4lmFZfhJe2ROw7skOtg_1W9wgZE,19618
14
+ python_fastllm-0.0.60.dist-info/METADATA,sha256=IL1bXk4TbnCuK8kw9D7DhmIJsiDZa8gLlUFkou29iAU,9136
15
+ python_fastllm-0.0.60.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
16
+ python_fastllm-0.0.60.dist-info/entry_points.txt,sha256=WkWd04F3xVYFP5bsexaQe9Se6uelwE5ULoTtHWreO0s,133
17
+ python_fastllm-0.0.60.dist-info/top_level.txt,sha256=F8qodL7nEGUHGmzzqfhNKCTIr1i0D6cvudOnm3z7o0Y,8
18
+ python_fastllm-0.0.60.dist-info/RECORD,,
@@ -1,18 +0,0 @@
1
- fastllm/__init__.py,sha256=6tGHvtWA9S06PKl5eFmjR7B9sGVZhtZsXqaK_d39b7k,131
2
- fastllm/_modidx.py,sha256=qzUtLbjOche-GNbeNun3VRIQdcm5g4dUOCLJ6T9fG-w,46374
3
- fastllm/acomplete.py,sha256=CusLue35xiaswpmTZyLkJj6t4CVsNDeeY3Sk7hzVNoY,10777
4
- fastllm/anthropic.py,sha256=ktH-EuQMQnp8O2_6VmSrvvDCIe2QTeSR7jNpWfqf3L4,18884
5
- fastllm/chat.py,sha256=klji3SOYs5xyzrSFbNhy3uoQ2TSZ3a-8HTjvdRtaG5Q,25036
6
- fastllm/codex.py,sha256=r6auZ8egia83aGkWcF4132AzW4SsBvKeDwg_CxyXj9E,5808
7
- fastllm/gemini.py,sha256=vpY_f4Jqn344l9CANm6_c7Cmhjq2URXxWhdVv_LswSg,17615
8
- fastllm/model_prices.json,sha256=pa0j96LZgklYii8howX443QbukUP1iFr3lBcucS-6Yo,2133018
9
- fastllm/openai_chat.py,sha256=n5INfDCsAZIiKUImWeaX6SJ_OAJ_uMhw20HUkgFgdgM,12379
10
- fastllm/openai_responses.py,sha256=Jbu8ddPC3rqlLgubOuOva4EWEtN6dk47MbwBfB8d9ng,15409
11
- fastllm/responses.py,sha256=9gh-UlhfswCcP0RCzLuPT5meWNh9mF_OIivzCzZU94I,22726
12
- fastllm/streaming.py,sha256=DrqNJkMhyntFKokY6GF6oW3CjELrk5XWmnxwcApJUyM,8770
13
- fastllm/types.py,sha256=W8t38EN1ZAzZaZ8QMi-Vin3fMxEpRhEZ8pm6fM5G3NA,19673
14
- python_fastllm-0.0.59.dist-info/METADATA,sha256=fCzlAUhirh1MHNym6IjGbVAlG0ouKo0EAP9n5DVF1m0,9136
15
- python_fastllm-0.0.59.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
16
- python_fastllm-0.0.59.dist-info/entry_points.txt,sha256=WkWd04F3xVYFP5bsexaQe9Se6uelwE5ULoTtHWreO0s,133
17
- python_fastllm-0.0.59.dist-info/top_level.txt,sha256=F8qodL7nEGUHGmzzqfhNKCTIr1i0D6cvudOnm3z7o0Y,8
18
- python_fastllm-0.0.59.dist-info/RECORD,,