python-fastllm 0.0.53__py3-none-any.whl → 0.0.55__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.53"
5
+ __version__ = "0.0.55"
fastllm/_modidx.py CHANGED
@@ -23,6 +23,7 @@ d = { 'settings': { 'branch': 'main',
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
25
  'fastllm.anthropic._sanid': ('anthropic.html#_sanid', 'fastllm/anthropic.py'),
26
+ 'fastllm.anthropic._url_cite': ('anthropic.html#_url_cite', 'fastllm/anthropic.py'),
26
27
  'fastllm.anthropic.acollect_stream': ('anthropic.html#acollect_stream', 'fastllm/anthropic.py'),
27
28
  'fastllm.anthropic.apply_cache_idxs': ('anthropic.html#apply_cache_idxs', 'fastllm/anthropic.py'),
28
29
  'fastllm.anthropic.collate_raw': ('anthropic.html#collate_raw', 'fastllm/anthropic.py'),
@@ -52,7 +53,7 @@ d = { 'settings': { 'branch': 'main',
52
53
  'fastllm.anthropic.norm_tr_parts': ('anthropic.html#norm_tr_parts', 'fastllm/anthropic.py'),
53
54
  'fastllm.anthropic.norm_usage': ('anthropic.html#norm_usage', 'fastllm/anthropic.py'),
54
55
  'fastllm.anthropic.raw_msg': ('anthropic.html#raw_msg', 'fastllm/anthropic.py'),
55
- 'fastllm.anthropic.search_hits': ('anthropic.html#search_hits', 'fastllm/anthropic.py')},
56
+ 'fastllm.anthropic.search_results': ('anthropic.html#search_results', 'fastllm/anthropic.py')},
56
57
  'fastllm.chat': { 'fastllm.chat.AsyncChat': ('chat.html#asyncchat', 'fastllm/chat.py'),
57
58
  'fastllm.chat.AsyncChat.__call__': ('chat.html#asyncchat.__call__', 'fastllm/chat.py'),
58
59
  'fastllm.chat.AsyncChat.__init__': ('chat.html#asyncchat.__init__', 'fastllm/chat.py'),
@@ -139,8 +140,10 @@ d = { 'settings': { 'branch': 'main',
139
140
  'fastllm.codex.main': ('codex.html#main', 'fastllm/codex.py'),
140
141
  'fastllm.codex.mk_jwt': ('codex.html#mk_jwt', 'fastllm/codex.py'),
141
142
  'fastllm.codex.token_expired': ('codex.html#token_expired', 'fastllm/codex.py')},
142
- 'fastllm.gemini': { 'fastllm.gemini._gem_filter_sch': ('gemini.html#_gem_filter_sch', 'fastllm/gemini.py'),
143
+ 'fastllm.gemini': { 'fastllm.gemini._fn_calls': ('gemini.html#_fn_calls', 'fastllm/gemini.py'),
144
+ 'fastllm.gemini._gem_filter_sch': ('gemini.html#_gem_filter_sch', 'fastllm/gemini.py'),
143
145
  'fastllm.gemini._gem_part_type': ('gemini.html#_gem_part_type', 'fastllm/gemini.py'),
146
+ 'fastllm.gemini._url_cite': ('gemini.html#_url_cite', 'fastllm/gemini.py'),
144
147
  'fastllm.gemini.acollect_stream': ('gemini.html#acollect_stream', 'fastllm/gemini.py'),
145
148
  'fastllm.gemini.collate_raw': ('gemini.html#collate_raw', 'fastllm/gemini.py'),
146
149
  'fastllm.gemini.cost': ('gemini.html#cost', 'fastllm/gemini.py'),
@@ -162,6 +165,7 @@ d = { 'settings': { 'branch': 'main',
162
165
  'fastllm.gemini.denorm_web_search': ('gemini.html#denorm_web_search', 'fastllm/gemini.py'),
163
166
  'fastllm.gemini.endpoint': ('gemini.html#endpoint', 'fastllm/gemini.py'),
164
167
  'fastllm.gemini.get_hdrs': ('gemini.html#get_hdrs', 'fastllm/gemini.py'),
168
+ 'fastllm.gemini.grounding': ('gemini.html#grounding', 'fastllm/gemini.py'),
165
169
  'fastllm.gemini.mk_payload': ('gemini.html#mk_payload', 'fastllm/gemini.py'),
166
170
  'fastllm.gemini.norm_finish': ('gemini.html#norm_finish', 'fastllm/gemini.py'),
167
171
  'fastllm.gemini.norm_parts': ('gemini.html#norm_parts', 'fastllm/gemini.py'),
@@ -253,8 +257,9 @@ d = { 'settings': { 'branch': 'main',
253
257
  'fastllm/openai_responses.py'),
254
258
  'fastllm.openai_responses.norm_usage': ( 'oai_responses.html#norm_usage',
255
259
  'fastllm/openai_responses.py'),
256
- 'fastllm.openai_responses.raw_msg': ( 'oai_responses.html#raw_msg',
257
- 'fastllm/openai_responses.py')},
260
+ 'fastllm.openai_responses.raw_msg': ('oai_responses.html#raw_msg', 'fastllm/openai_responses.py'),
261
+ 'fastllm.openai_responses.search_hits': ( 'oai_responses.html#search_hits',
262
+ 'fastllm/openai_responses.py')},
258
263
  'fastllm.responses': { 'fastllm.responses.AsyncResponses': ('responses.html#asyncresponses', 'fastllm/responses.py'),
259
264
  'fastllm.responses.AsyncResponses.__init__': ( 'responses.html#asyncresponses.__init__',
260
265
  'fastllm/responses.py'),
@@ -317,7 +322,8 @@ d = { 'settings': { 'branch': 'main',
317
322
  'fastllm.responses.response_input': ('responses.html#response_input', 'fastllm/responses.py'),
318
323
  'fastllm.responses.response_object': ('responses.html#response_object', 'fastllm/responses.py'),
319
324
  'fastllm.responses.response_output': ('responses.html#response_output', 'fastllm/responses.py'),
320
- 'fastllm.responses.response_usage': ('responses.html#response_usage', 'fastllm/responses.py')},
325
+ 'fastllm.responses.response_usage': ('responses.html#response_usage', 'fastllm/responses.py'),
326
+ 'fastllm.responses.server_call_item': ('responses.html#server_call_item', 'fastllm/responses.py')},
321
327
  'fastllm.streaming': { 'fastllm.streaming.Delta': ('streaming.html#delta', 'fastllm/streaming.py'),
322
328
  'fastllm.streaming.Delta.__eq__': ('streaming.html#delta.__eq__', 'fastllm/streaming.py'),
323
329
  'fastllm.streaming.Delta.__hash__': ('streaming.html#delta.__hash__', 'fastllm/streaming.py'),
fastllm/acomplete.py CHANGED
@@ -39,7 +39,7 @@ vendor_mapping = {
39
39
  "fireworks_ai": ('openai_chat', "https://api.fireworks.ai/inference/v1", "FIREWORKS_API_KEY"),
40
40
  "qwen": ('openai_chat', "https://dashscope.aliyuncs.com/compatible-mode/v1", "QWEN_API_KEY"),
41
41
  "minimax": ('anthropic', "https://api.minimax.io/anthropic", "MINIMAX_API_KEY"),
42
- "meta_ai": ('openai', "https://api.meta.ai/v1", "META_API_KEY"),
42
+ "meta": ('openai', "https://api.meta.ai/v1", "META_API_KEY"),
43
43
  "zai": ('openai_chat', "https://api.z.ai/api/paas/v4", "ZAI_API_KEY")}
44
44
 
45
45
  # %% ../nbs/06_acomplete.ipynb #e3ed40fb
fastllm/anthropic.py CHANGED
@@ -1,7 +1,7 @@
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', 'search_hits',
4
+ __all__ = ['ant_tc_types', 'norm_tool_call', 'norm_tool_calls', 'norm_usage', 'finalize_usage', 'norm_finish', 'search_results',
5
5
  'norm_parts', 'raw_msg', 'norm_tr_parts', 'norm_sse_event', 'delta_index_fn', 'collate_raw',
6
6
  'acollect_stream', 'denorm_tool_use', 'denorm_assistant', 'denorm_tool', 'denorm_msgs', 'denorm_tool_schs',
7
7
  'denorm_tool_choice', 'denorm_reasoning', 'denorm_web_search', 'denorm_system', 'denorm_user',
@@ -15,7 +15,7 @@ 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, Completion, Text, Thinking, ToolUse, ToolResult, tool_text,
18
+ from aidialog.msg_parts import (MdStr, 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
@@ -63,30 +63,36 @@ def norm_finish(resp, tcs=None):
63
63
  r = mp.get(reason, reason)
64
64
  return FinishReason.tool_calls if r==FinishReason.stop and any(~L(tcs).attrgot('server')) else r
65
65
 
66
+ # %% ../nbs/04_anthropic.ipynb #9796be28
67
+ def search_results(parts, blocks):
68
+ "Give each server call the sources its `web_search_tool_result` block reports, one `title: url` line each, or the block's error code"
69
+ calls = {p.id: p for p in parts if isinstance(p, ToolUse) and p.server}
70
+ for b in blocks:
71
+ if b.get('type') != 'web_search_tool_result' or not (call := calls.get(b.get('tool_use_id'))): continue
72
+ res = b.get('content')
73
+ call.text = res.get('error_code', '') if isinstance(res, dict) else MdStr('\n'.join(f"{r.get('title', '')}: {r['url']}" for r in res if r.get('url')))
74
+ return parts
75
+
66
76
  # %% ../nbs/04_anthropic.ipynb #20d2e803
67
77
  def _ant_part_type(typ):
68
78
  "Map Anthropic content block type to canonical PartType."
69
79
  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
80
  return ant_parts_mapping.get(typ, typ)
71
81
 
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
82
+ def _url_cite(c): return dict(type='url_citation', url=c['url'], title=c.get('title', ''))
79
83
 
80
84
  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`"
85
+ "Canonical parts of a response; a text block's web citations become `url_citation`s"
82
86
  parts = []
83
87
  for b in resp.get("content", []):
84
88
  typ = _ant_part_type(b.get("type", "text"))
85
89
  if typ == PartType.thinking: parts.append(Thinking(b.get("thinking", ""), raw=b))
86
90
  elif typ == PartType.tool_use:
87
91
  if tc := norm_tool_call(b): parts.append(tc)
88
- elif typ == PartType.text: parts.append(Text(b.get("text", ""), raw=b))
89
- return search_hits(parts, resp.get("content", []))
92
+ elif typ == PartType.text:
93
+ cites = [_url_cite(c) for c in b.get('citations') or [] if c.get('url')]
94
+ parts.append(Text(b.get("text", ""), citations=cites, raw=b))
95
+ return search_results(parts, resp.get("content", []))
90
96
 
91
97
  # %% ../nbs/04_anthropic.ipynb #f24e52db
92
98
  def raw_msg(resp):
@@ -124,8 +130,7 @@ def norm_sse_event(ev, **kwargs):
124
130
  elif dtyp == "thinking_delta": thinking = d.get("thinking")
125
131
  elif dtyp == "input_json_delta":
126
132
  tcs = [ToolUse(id=str(ev.get("index", "")), name="", arguments={"_delta": d.get("partial_json", '')})]
127
- elif dtyp == "citations_delta":
128
- citations = listify(d.get('citation',[]))
133
+ elif dtyp == "citations_delta": citations = [_url_cite(c) for c in listify(d.get('citation', [])) if c.get('url')]
129
134
  elif typ == "message_delta":
130
135
  fin = norm_finish(ev.get('delta'))
131
136
  return Delta(finish_reason=fin, usage=norm_usage(ev), raw=ev, **kwargs)
@@ -159,7 +164,7 @@ def collate_raw(deltas):
159
164
  async def acollect_stream(resp, **kwargs):
160
165
  res = mk_acollect_stream(norm_and_yield(resp, norm_sse_event), index_fn=delta_index_fn, api_name='anthropic', **kwargs)
161
166
  async for o in res:
162
- if isinstance(o, Completion): search_hits(o.message.content, o.message.raw['content'])
167
+ if isinstance(o, Completion): search_results(o.message.content, o.message.raw['content'])
163
168
  yield o
164
169
 
165
170
  # %% ../nbs/04_anthropic.ipynb #bb11ab62
fastllm/gemini.py CHANGED
@@ -1,21 +1,21 @@
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', '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']
4
+ __all__ = ['api_ns', 'endpoint', 'grounding', 'norm_tool_calls', 'norm_usage', 'norm_finish', 'norm_parts', 'raw_msg',
5
+ 'norm_sse_event', 'delta_index_fn', 'collate_raw', 'acollect_stream', 'denorm_tool_use', 'denorm_assistant',
6
+ 'denorm_tool', 'denorm_msgs', 'denorm_tool_schs', 'denorm_tool_choice', 'denorm_reasoning',
7
+ 'denorm_web_search', 'denorm_system', 'denorm_user', 'denorm_image', 'denorm_audio', 'denorm_video',
8
+ 'denorm_file', 'denorm_tool_result', 'mk_payload', 'get_hdrs', 'cost']
9
9
 
10
10
  # %% ../nbs/05_gemini.ipynb #02afd3d7
11
- import json
11
+ import html
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, Msg, Text, Thinking, ToolUse, ToolResult, tool_text,
18
+ from aidialog.msg_parts import (MdStr, Part, PartType, Msg, Completion, Text, Thinking, ToolUse, ToolResult, tool_text,
19
19
  InputImage, InputAudio, InputVideo, InputFile, sys_text, part_txt, data_url, url_mime)
20
20
  from .streaming import *
21
21
  from .streaming import mk_acollect_stream
@@ -26,16 +26,30 @@ def endpoint(model, stream):
26
26
  return f'v1beta/{model}:{"streamGenerateContent" if stream else "generateContent"}', ({'alt': 'sse'} if stream else None)
27
27
 
28
28
  # %% ../nbs/05_gemini.ipynb #f1eb32f3
29
+ def _url_cite(c): return dict(type='url_citation', url=c['web']['uri'], title=c['web'].get('title', ''))
30
+
31
+ def grounding(cand):
32
+ "The `url_citation`s and the `google_search` call a candidate's `groundingMetadata` or `toolCall` parts describe, or `([], None)` without either; the call's `text` lists the sources and the Google Search suggestion links"
33
+ gm = cand.get('groundingMetadata') or {}
34
+ tcs = [p['toolCall'] for p in nested_idx(cand, 'content', 'parts') or [] if 'toolCall' in p]
35
+ if not gm and not tcs: return [], None
36
+ cites = [_url_cite(c) for c in gm.get('groundingChunks', []) if nested_idx(c, 'web', 'uri')]
37
+ chips = re.findall(r'<a class="chip" href="([^"]+)">([^<]*)</a>', nested_idx(gm, 'searchEntryPoint', 'renderedContent') or '')
38
+ queries = gm.get('webSearchQueries') or [q for tc in tcs for q in (tc.get('args') or {}).get('queries', [])]
39
+ lines = [f"{c['title']}: {c['url']}" for c in cites] + [f"Search suggestion: [{html.unescape(t)}]({html.unescape(u)})" for u,t in chips]
40
+ call = ToolUse(id='google_search', name='google_search', arguments=dict(queries=queries), server=True, text=MdStr('\n'.join(lines)))
41
+ return cites, call
42
+
43
+ # %% ../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"))]
47
+
29
48
  def norm_tool_calls(resp):
30
- "Tool calls of a response: `functionCall` parts, and a `toolCall` part for a search the API ran itself"
31
- out = []
32
- for i,p in enumerate(nested_idx(resp, 'candidates', 0, 'content', 'parts') or []):
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))
38
- return out
49
+ "Tool calls of a response: the `google_search` call its grounding describes, then its `functionCall` parts"
50
+ cand = nested_idx(resp, 'candidates', 0) or {}
51
+ _,call = grounding(cand)
52
+ return ([call] if call else []) + _fn_calls(nested_idx(cand, 'content', 'parts') or [])
39
53
 
40
54
  # %% ../nbs/05_gemini.ipynb #90504163
41
55
  def norm_usage(resp):
@@ -50,7 +64,7 @@ def norm_usage(resp):
50
64
  cand = nested_idx(resp, 'candidates', 0) or {}
51
65
  stu = {}
52
66
  if any("executableCode" in p for p in parts): stu["code_execution"] = sum(1 for p in parts if "executableCode" in p)
53
- if n := ("groundingMetadata" in cand) + sum(1 for p in parts if 'toolCall' in p): stu["google_search"] = n
67
+ if call := grounding(cand)[1]: stu['google_search'] = len(call.arguments['queries'] or [1])
54
68
  if stu: usg['server_tool_use'] = stu
55
69
  return Usage(prompt_tokens=pt, completion_tokens=ct, total_tokens=tt,
56
70
  cached_tokens=cached, reasoning_tokens=reasoning, raw=usg)
@@ -67,28 +81,26 @@ def norm_finish(resp, tcs=None):
67
81
  # %% ../nbs/05_gemini.ipynb #81c2ce3f
68
82
  def _gem_part_type(p):
69
83
  "Map Gemini part to canonical PartType."
70
- if 'functionCall' in p or 'toolCall' in p: return PartType.tool_use
84
+ if 'functionCall' in p: return PartType.tool_use
71
85
  if 'executableCode' in p or 'codeExecutionResult' in p: return PartType.tool_result
72
86
  if p.get('thought'): return PartType.thinking
73
87
  return PartType.text
74
88
 
75
89
  def norm_parts(resp):
76
- "Normalize Gemini generateContent response."
90
+ "Normalize Gemini generateContent response; a search's `toolCall` and `toolResponse` parts give way to the call `grounding` builds"
77
91
  c0 = nested_idx(resp, 'candidates', 0) or {}
92
+ cites,call = grounding(c0)
78
93
  tc_map = {tc.id: tc for tc in norm_tool_calls(resp)}
79
- parts = []
94
+ parts = [call] if call else []
80
95
  for p in nested_idx(c0, 'content', 'parts') or []:
81
- if 'toolResponse' in p: continue
96
+ if 'toolCall' in p or 'toolResponse' in p: continue
82
97
  typ = _gem_part_type(p)
83
98
  if typ == PartType.tool_use:
84
- fc = p.get('functionCall') or p.get('toolCall') or {}
85
- if tc := tc_map.get(fc.get('id')): parts.append(tc)
99
+ if tc := tc_map.get(p['functionCall'].get('id')): parts.append(tc)
86
100
  elif typ == PartType.thinking: parts.append(Thinking(p.get("text",""), raw=p))
87
101
  elif typ == PartType.tool_result: parts.append(ToolResult(text=p.get("text",""), raw=p))
88
102
  else: parts.append(Text(p.get("text",""), raw=p))
89
- if citations := c0.get('groundingMetadata'):
90
- for p in parts:
91
- if isinstance(p, Text): p.citations = citations
103
+ if txts := [p for p in parts if isinstance(p, Text)]: txts[0].citations = cites
92
104
  return parts
93
105
 
94
106
  # %% ../nbs/05_gemini.ipynb #3acc9538
@@ -105,9 +117,10 @@ def norm_sse_event(ev, **kwargs):
105
117
  parts = nested_idx(cand, 'content', 'parts') or []
106
118
  thinking = "".join(p.get("text","") for p in parts if p.get("thought") and "text" in p)
107
119
  txt = "".join(p.get("text","") for p in parts if not p.get("thought") and "text" in p)
108
- tcs = norm_tool_calls(ev)
120
+ cites,call = grounding(cand) if 'groundingMetadata' in cand else ([], None) # a search is reported once its grounding arrives
121
+ tcs = _fn_calls(parts) + ([call] if call else [])
109
122
  if ev.get("error"): raise api_error_from_event(ev)
110
- return Delta(text=txt, thinking=thinking, tool_calls=tcs, citations=listify(cand.get('groundingMetadata', [])), finish_reason=finish_reason, usage=norm_usage(ev), raw=ev, **kwargs)
123
+ return Delta(text=txt, thinking=thinking, tool_calls=tcs, citations=cites, finish_reason=finish_reason, usage=norm_usage(ev), raw=ev, **kwargs)
111
124
 
112
125
  # %% ../nbs/05_gemini.ipynb #ebd6fdd4
113
126
  def delta_index_fn(d, typ, last_typ, last_idx):
@@ -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', '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']
4
+ __all__ = ['api_ns', 'norm_tool_call', 'norm_tool_calls', 'norm_usage', 'norm_finish', 'search_hits', 'norm_parts', 'raw_msg',
5
+ 'norm_sse_event', 'delta_index_fn', 'collate_raw', 'acollect_stream', 'denorm_tool_use', 'denorm_tool',
6
+ 'denorm_assistant', 'denorm_msgs', 'denorm_tool_schs', 'denorm_tool_choice', 'denorm_reasoning',
7
+ 'denorm_web_search', 'denorm_system', 'denorm_user', 'denorm_image', 'denorm_video', 'denorm_file',
8
+ 'denorm_tool_result', 'mk_payload', 'get_hdrs', 'cost', 'fix_payload']
9
9
 
10
10
  # %% ../nbs/02_oai_responses.ipynb #591f55b5
11
11
  import json
@@ -15,7 +15,7 @@ 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 (Completion, Part, PartType, Msg, Text, Thinking, Refusal, ToolUse, ToolResult, tool_text,
18
+ from aidialog.msg_parts import (Completion, Part, PartType, Msg, Text, Thinking, Refusal, ToolUse, ToolResult, tool_text, MdStr,
19
19
  InputImage, InputAudio, InputVideo, InputFile, mk_tool_res_msg, sys_text, part_txt, data_url)
20
20
  from .streaming import *
21
21
  from .streaming import mk_acollect_stream
@@ -26,10 +26,11 @@ def norm_tool_call(item):
26
26
  if typ == "function_call":
27
27
  raw = {k:v for k,v in item.items() if k not in ("call_id","name","arguments")}
28
28
  return ToolUse(id=item.get("call_id") or item["id"], name=item["name"], arguments=json.loads(item["arguments"]), raw=raw)
29
- if typ.endswith("_call"): # web_search_call, file_search_call, etc.
30
- name = typ.removesuffix("_call")
31
- raw = {k:v for k,v in item.items() if k not in ("id")}
32
- return ToolUse(id=item.get("id",""), name=name, arguments=item.get("action", {}), server=True, raw=raw)
29
+ if typ.endswith("_call"): # a call the server ran: OpenAI's hosted items carry an `action`, a Responses server's `server_tool_call` its `arguments` and `output` (`md` when that output is markdown)
30
+ raw = {k:v for k,v in item.items() if k not in ("id","name","arguments","output","action","md")}
31
+ args = json.loads(item["arguments"]) if "arguments" in item else item.get("action", {})
32
+ out = item.get("output")
33
+ return ToolUse(id=item.get("id",""), name=item.get("name") or typ.removesuffix("_call"), arguments=args, server=True, text=MdStr(out) if item.get("md") else out, raw=raw)
33
34
 
34
35
  def norm_tool_calls(resp):
35
36
  "Extract Responses API tool call items as normalized tool calls."
@@ -63,6 +64,14 @@ def norm_finish(resp, tcs=None):
63
64
  r = mp.get(reason, reason)
64
65
  return FinishReason.tool_calls if r==FinishReason.stop and any(~L(tcs).attrgot('server')) else r
65
66
 
67
+ # %% ../nbs/02_oai_responses.ipynb #bbfb99b9
68
+ def search_hits(parts):
69
+ "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
+ hits = {c['url']: c.get('title', '') for p in parts if isinstance(p, Text) for c in p.citations or [] if c.get('url')}
71
+ for p in parts:
72
+ 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()))
73
+ return parts
74
+
66
75
  # %% ../nbs/02_oai_responses.ipynb #4ef27720
67
76
  def norm_parts(resp):
68
77
  parts = []
@@ -76,7 +85,7 @@ def norm_parts(resp):
76
85
  elif typ == "reasoning":
77
86
  for s in item["summary"]: parts.append(Thinking(s['text'], raw=item))
78
87
  elif typ == "function_call" or typ.endswith("_call"): parts.append(norm_tool_call(item))
79
- return parts
88
+ return search_hits(parts)
80
89
 
81
90
  # %% ../nbs/02_oai_responses.ipynb #2aaab962
82
91
  def raw_msg(resp):
@@ -128,7 +137,9 @@ def collate_raw(deltas):
128
137
  @delegates(mk_acollect_stream, but=['index_fn', 'api_name'])
129
138
  async def acollect_stream(resp, **kwargs):
130
139
  res = mk_acollect_stream(norm_and_yield(resp, norm_sse_event), index_fn=delta_index_fn, api_name='openai', **kwargs)
131
- async for o in res: yield o
140
+ async for o in res:
141
+ if isinstance(o, Completion): search_hits(o.message.content)
142
+ yield o
132
143
 
133
144
  # %% ../nbs/02_oai_responses.ipynb #9608e813
134
145
  def _sanid(id_str): return id_str[:64] # codex max 64 char limit
fastllm/responses.py CHANGED
@@ -11,8 +11,9 @@ Every request is streamed. `_ResponseStream` only tracks the standard event gram
11
11
  # AUTOGENERATED! DO NOT EDIT! File to edit: ../nbs/06a_responses.ipynb.
12
12
 
13
13
  # %% auto #0
14
- __all__ = ['ResponsesError', 'response_input', 'normalize_call_ids', 'response_output', 'response_usage', 'response_object',
15
- 'ResponseState', 'ResponseStore', 'ResponseTurn', 'AsyncResponses', 'response_event', 'pending_response']
14
+ __all__ = ['ResponsesError', 'response_input', 'normalize_call_ids', 'server_call_item', 'response_output', 'response_usage',
15
+ 'response_object', 'ResponseState', 'ResponseStore', 'ResponseTurn', 'AsyncResponses', 'response_event',
16
+ 'pending_response']
16
17
 
17
18
  # %% ../nbs/06a_responses.ipynb #e16fc24e
18
19
  import asyncio, copy, json, secrets, time
@@ -22,7 +23,7 @@ from fasttransport.errors import APIError
22
23
 
23
24
  from .acomplete import acomplete
24
25
  from .streaming import Status
25
- from aidialog.msg_parts import Msg, Text, ToolUse, ToolResult, InputImage, InputFile, Completion, tool_text, msg2dict, dict2msg
26
+ from aidialog.msg_parts import Msg, Text, ToolUse, ToolResult, InputImage, InputFile, Completion, MdStr, tool_text, msg2dict, dict2msg
26
27
 
27
28
  # %% ../nbs/06a_responses.ipynb #f904c16f
28
29
  class ResponsesError(Exception):
@@ -93,6 +94,13 @@ def normalize_call_ids(comp):
93
94
  # %% ../nbs/06a_responses.ipynb #ab1a57d7
94
95
  def _arguments(args): return args if isinstance(args, str) else json.dumps(args or {}, separators=(',', ':'))
95
96
 
97
+ def server_call_item(part):
98
+ "The output item for a call the server ran, whether a host tool or the provider's own: a `server_tool_call` carrying its `arguments` and result `output`, marked `md` when that output is markdown"
99
+ item = dict(type='server_tool_call', name=part.name, arguments=_arguments(part.arguments), output=part.text or '')
100
+ if isinstance(part.text, MdStr): item['md'] = True
101
+ part._response_item_id = getattr(part, '_response_item_id', None) or _new_id('srv')
102
+ return dict(id=part._response_item_id, status='completed', **item)
103
+
96
104
  def response_output(comp):
97
105
  "Convert a Completion to Responses output items."
98
106
  output,msg = [],None
@@ -106,8 +114,9 @@ def response_output(comp):
106
114
  else:
107
115
  msg['content'][0]['text'] += part.text or ''
108
116
  msg['content'][0]['annotations'].extend(part.citations or [])
109
- elif isinstance(part, ToolUse) and not part.server:
110
- output.append(dict(id=getattr(part, '_response_item_id', None) or _new_id('fc'), type='function_call', status='completed',
117
+ elif isinstance(part, ToolUse):
118
+ if part.server: output.append(server_call_item(part))
119
+ else: output.append(dict(id=getattr(part, '_response_item_id', None) or _new_id('fc'), type='function_call', status='completed',
111
120
  call_id=part.id, name=part.name, arguments=_arguments(part.arguments)))
112
121
  return output
113
122
 
@@ -337,13 +346,21 @@ def part_events(self:_ResponseStream, part):
337
346
  def done_events(self:_ResponseStream, comp):
338
347
  events = []
339
348
  if self.message_id is not None:
340
- content = dict(type='output_text', text=self.text, annotations=[])
349
+ cites = [c for p in comp.message.content if isinstance(p, Text) for c in p.citations or []]
350
+ content = dict(type='output_text', text=self.text, annotations=cites)
341
351
  item = dict(id=self.message_id, type='message', status='completed', role='assistant', content=[content])
342
352
  events.append(self.event('response.output_text.done', item_id=self.message_id, output_index=self.message_index,
343
353
  content_index=0, text=self.text))
344
354
  events.append(self.event('response.content_part.done', item_id=self.message_id, output_index=self.message_index,
345
355
  content_index=0, part=content))
356
+ for i,c in enumerate(content['annotations']):
357
+ events.append(self.event('response.output_text.annotation.added', item_id=self.message_id, output_index=self.message_index,
358
+ content_index=0, annotation_index=i, annotation=c))
346
359
  events.append(self.event('response.output_item.done', output_index=self.message_index, item=item))
360
+ for part in comp.message.content:
361
+ if isinstance(part, ToolUse) and part.server:
362
+ events.append(self.event('response.output_item.done', output_index=self.next_index, item=server_call_item(part)))
363
+ self.next_index += 1
347
364
  for i,tc in enumerate(comp.tool_calls):
348
365
  if not tc.id and i < len(self.streamed_call_ids): tc.id = self.streamed_call_ids[i]
349
366
  tc._response_item_id = self.tool_item_ids.get(tc.id)
@@ -367,7 +384,8 @@ async def events(self:AsyncResponses, turn, finalize=None, stream=None):
367
384
  usage,stream = (comp.usage if usage is None else usage + comp.usage),None
368
385
  results = await self.server_results(turn, comp)
369
386
  if not results or len(results) < len(comp.tool_calls): break
370
- texts += [p for p in comp.message.content if isinstance(p, Text)]
387
+ 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]
371
389
  turn = self.server_turn(turn, comp, results)
372
390
  next_state = self.state(turn, comp, results)
373
391
  comp = copy.copy(comp)
@@ -381,7 +399,6 @@ async def events(self:AsyncResponses, turn, finalize=None, stream=None):
381
399
  except ResponsesError as exc: yield state.event('error', code=exc.code, message=str(exc), param=exc.param)
382
400
  except APIError as exc: yield state.event('error', code=exc.code or 'provider_error', message=str(exc), param=None)
383
401
 
384
-
385
402
  # %% ../nbs/06a_responses.ipynb #cc32ce40
386
403
  @patch
387
404
  def serve(self:AsyncResponses, turn, finalize=None, stream=None, timeout=None):
fastllm/streaming.py CHANGED
@@ -76,6 +76,7 @@ class PartAccum(BasicRepr):
76
76
  if isinstance(p.arguments, str): p.arguments = json.loads(p.arguments) if p.arguments else {}
77
77
  elif merged and type(merged[-1]) is type(p) and isinstance(p, (Text, Thinking)):
78
78
  merged[-1].text += p.text
79
+ if isinstance(p, Text): merged[-1].citations.extend(p.citations)
79
80
  continue
80
81
  merged.append(p)
81
82
  return merged
@@ -163,7 +164,7 @@ async def mk_acollect_stream(it, index_fn, model=None, api_name=None, vendor_nam
163
164
  # Rest incl. tools, finish reason, usage is processed independently
164
165
  for tc in d.tool_calls:
165
166
  args = tc.arguments.get('_delta', tc.arguments)
166
- _, idx = _proc(d, 'tool_use', ret=dict(id=tc.id, name=tc.name, arguments=args, server=tc.server, raw=tc.raw))
167
+ _, idx = _proc(d, 'tool_use', ret=dict(id=tc.id, name=tc.name, arguments=args, server=tc.server, text=tc.text, raw=tc.raw))
167
168
  if (isinstance(args, str) and args.endswith('}')) or (isinstance(args, dict) and '_delta' not in tc.arguments): # tool call ready
168
169
  acc = part_accum.parts[idx]
169
170
  if isinstance(args, str):
@@ -171,7 +172,6 @@ async def mk_acollect_stream(it, index_fn, model=None, api_name=None, vendor_nam
171
172
  except json.JSONDecodeError: continue
172
173
  acc.arguments = args
173
174
  yield acc
174
- if acc.server: yield ToolResult(id=acc.id, name=acc.name, arguments=args, server=True, text="Server tool call executed.")
175
175
  r = _proc(d, 'refusal')
176
176
  if r[0]: yield _mk_delta_part('refusal', r[0]['refusal'])
177
177
  if d.finish_reason: fin = d.finish_reason
fastllm/types.py CHANGED
@@ -22,7 +22,6 @@ from fastcore.utils import *
22
22
  from PIL import Image as PImg
23
23
  from aidialog.msg_parts import Msg, Thinking, ToolUse, Completion, StopResponse, FullResponse
24
24
 
25
-
26
25
  # %% ../nbs/00_types.ipynb #802ad832
27
26
  class Usage(BasicRepr):
28
27
  "Normalized usage."
@@ -293,11 +292,6 @@ register_model_info('mimo-v2.5-pro-ultraspeed', vendor_name='mimo', **mimo_v25_c
293
292
  register_model_info('MiniMax-M3', vendor_name='minimax', **modern_llm, max_input_tokens=512_000, max_output_tokens=512_000, max_tokens=512_000,
294
293
  input_cost_per_token=0.3e-6, output_cost_per_token=1.2e-6, cache_read_input_token_cost=0.06e-6, supports_vision=True, supports_video_input=True)
295
294
 
296
- # %% ../nbs/00_types.ipynb #4e1b40b3
297
- register_model_info('muse-spark-1.1', vendor_name='meta_ai', **modern_llm, max_input_tokens=1_048_576, max_output_tokens=128000, max_tokens=128000,
298
- supports_vision=True, supports_image_input=True, supports_video_input=True, supports_pdf_input=True, supports_web_search=True,
299
- search_context_cost_per_query=0.0025, input_cost_per_token=1.25e-6, output_cost_per_token=4.25e-6, cache_read_input_token_cost=0.15e-6)
300
-
301
295
  # %% ../nbs/00_types.ipynb #2c23d11e
302
296
  codex_pricing = dict(input_cost_per_token = 0.10/1_000_000, output_cost_per_token = 0.50/1_000_000,
303
297
  cache_creation_input_token_cost = 0.10/1_000_000, cache_read_input_token_cost = 0.10/1_000_000)
@@ -332,8 +326,9 @@ for model in gpt56s:
332
326
 
333
327
  # %% ../nbs/00_types.ipynb #24cc47ec
334
328
  def get_model_pricing(mn, vendor_name, million=True):
329
+ "Token rates for a model, per million tokens by default; per-query and per-image prices are left out"
335
330
  return {k:round(v * (1e6 if million else 1), 6) for k,v in get_model_info(mn, vendor_name).items()
336
- if 'cost' in k and isinstance(v,float) and 'priority' not in k}
331
+ if 'cost' in k and 'token' in k and isinstance(v,float) and 'priority' not in k}
337
332
 
338
333
  # %% ../nbs/00_types.ipynb #79304cd9
339
334
  def approx_pricing(nm, vendor_name, out=10, cache=80, inp=10, markup=0):
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-fastllm
3
- Version: 0.0.53
3
+ Version: 0.0.55
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.26
13
+ Requires-Dist: aidialog>=0.0.27
14
14
  Requires-Dist: fasttransport>=0.0.2
15
15
  Requires-Dist: pillow
16
16
 
@@ -0,0 +1,17 @@
1
+ fastllm/__init__.py,sha256=iRT1X9KDx6ALQWPw-p0wlt7QqSDN3UqpvPe6xy9-niU,131
2
+ fastllm/_modidx.py,sha256=qzUtLbjOche-GNbeNun3VRIQdcm5g4dUOCLJ6T9fG-w,46374
3
+ fastllm/acomplete.py,sha256=64-k3LgLoX1EoWcHBS_-epZ-JvX5JL-As9QTo-WSzDc,10772
4
+ fastllm/anthropic.py,sha256=vsWkmIxIh8j7LTPfLzgrNggOl_2LuRKKcdVhLfDDdRE,18748
5
+ fastllm/chat.py,sha256=klji3SOYs5xyzrSFbNhy3uoQ2TSZ3a-8HTjvdRtaG5Q,25036
6
+ fastllm/codex.py,sha256=r6auZ8egia83aGkWcF4132AzW4SsBvKeDwg_CxyXj9E,5808
7
+ fastllm/gemini.py,sha256=978x7WDug5nSNvTOacap3bglgQhddahykFQvoOKOvhs,17571
8
+ fastllm/openai_chat.py,sha256=n5INfDCsAZIiKUImWeaX6SJ_OAJ_uMhw20HUkgFgdgM,12379
9
+ fastllm/openai_responses.py,sha256=O30JBRG0kTI31FPnzAOMEE8iiIsMBdAxVGoZys58Jn4,15288
10
+ fastllm/responses.py,sha256=9gh-UlhfswCcP0RCzLuPT5meWNh9mF_OIivzCzZU94I,22726
11
+ fastllm/streaming.py,sha256=DrqNJkMhyntFKokY6GF6oW3CjELrk5XWmnxwcApJUyM,8770
12
+ fastllm/types.py,sha256=hET6SNjqC8Z5PasrZ4uU-OObIfs3QyI0ZPY4jE2rh1Q,19268
13
+ python_fastllm-0.0.55.dist-info/METADATA,sha256=4S2QRq-6hLiji-8MTmAbzgXZvJ5k8U0SdVk8EC1-acA,18537
14
+ python_fastllm-0.0.55.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
15
+ python_fastllm-0.0.55.dist-info/entry_points.txt,sha256=WkWd04F3xVYFP5bsexaQe9Se6uelwE5ULoTtHWreO0s,133
16
+ python_fastllm-0.0.55.dist-info/top_level.txt,sha256=F8qodL7nEGUHGmzzqfhNKCTIr1i0D6cvudOnm3z7o0Y,8
17
+ python_fastllm-0.0.55.dist-info/RECORD,,
@@ -1,17 +0,0 @@
1
- fastllm/__init__.py,sha256=p94hx8eEtzJ2eou5yZY7R3C9ab8YAXbWNrFNQoK6S-s,131
2
- fastllm/_modidx.py,sha256=5WI8_unIttNYqjHBQBHf33C1OVgMgCcdO2SbdOk04Io,45637
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=ASTWk_dghOfJozQAm6QAZ8VnkGFA-sBLGVeS60DfP3E,21303
11
- fastllm/streaming.py,sha256=i_FU_Mt-3F2lBEImYav8I5pEjUSKYk3JFIW3lkiXEb8,8813
12
- fastllm/types.py,sha256=q4rZHTD9sghHI8j5cNQRWmrQW_NO8zYa0KEibKGiSyI,19603
13
- python_fastllm-0.0.53.dist-info/METADATA,sha256=5mJQJPAsu4tKE4Icowyjc1HaZCEE8xNRH7F1kUJVkKY,18537
14
- python_fastllm-0.0.53.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
15
- python_fastllm-0.0.53.dist-info/entry_points.txt,sha256=WkWd04F3xVYFP5bsexaQe9Se6uelwE5ULoTtHWreO0s,133
16
- python_fastllm-0.0.53.dist-info/top_level.txt,sha256=F8qodL7nEGUHGmzzqfhNKCTIr1i0D6cvudOnm3z7o0Y,8
17
- python_fastllm-0.0.53.dist-info/RECORD,,