python-fastllm 0.0.12__tar.gz → 0.0.15__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (28) hide show
  1. {python_fastllm-0.0.12 → python_fastllm-0.0.15}/PKG-INFO +2 -1
  2. python_fastllm-0.0.15/fastllm/__init__.py +1 -0
  3. {python_fastllm-0.0.12 → python_fastllm-0.0.15}/fastllm/_modidx.py +4 -1
  4. {python_fastllm-0.0.12 → python_fastllm-0.0.15}/fastllm/anthropic.py +11 -5
  5. {python_fastllm-0.0.12 → python_fastllm-0.0.15}/fastllm/chat.py +3 -3
  6. {python_fastllm-0.0.12 → python_fastllm-0.0.15}/fastllm/gemini.py +1 -0
  7. {python_fastllm-0.0.12 → python_fastllm-0.0.15}/fastllm/openai_chat.py +1 -0
  8. {python_fastllm-0.0.12 → python_fastllm-0.0.15}/fastllm/openai_responses.py +6 -2
  9. {python_fastllm-0.0.12 → python_fastllm-0.0.15}/fastllm/streaming.py +1 -5
  10. {python_fastllm-0.0.12 → python_fastllm-0.0.15}/fastllm/types.py +26 -10
  11. {python_fastllm-0.0.12 → python_fastllm-0.0.15}/pyproject.toml +1 -1
  12. {python_fastllm-0.0.12 → python_fastllm-0.0.15}/python_fastllm.egg-info/PKG-INFO +2 -1
  13. {python_fastllm-0.0.12 → python_fastllm-0.0.15}/python_fastllm.egg-info/requires.txt +1 -0
  14. python_fastllm-0.0.12/fastllm/__init__.py +0 -1
  15. {python_fastllm-0.0.12 → python_fastllm-0.0.15}/README.md +0 -0
  16. {python_fastllm-0.0.12 → python_fastllm-0.0.15}/fastllm/acomplete.py +0 -0
  17. {python_fastllm-0.0.12 → python_fastllm-0.0.15}/fastllm/codex.py +0 -0
  18. {python_fastllm-0.0.12 → python_fastllm-0.0.15}/fastllm/specs/anthropic.json +0 -0
  19. {python_fastllm-0.0.12 → python_fastllm-0.0.15}/fastllm/specs/anthropic.yml +0 -0
  20. {python_fastllm-0.0.12 → python_fastllm-0.0.15}/fastllm/specs/gemini.json +0 -0
  21. {python_fastllm-0.0.12 → python_fastllm-0.0.15}/fastllm/specs/openai.with-code-samples.json +0 -0
  22. {python_fastllm-0.0.12 → python_fastllm-0.0.15}/fastllm/specs/openai.with-code-samples.yml +0 -0
  23. {python_fastllm-0.0.12 → python_fastllm-0.0.15}/fastllm/specs/spec_manifest.json +0 -0
  24. {python_fastllm-0.0.12 → python_fastllm-0.0.15}/python_fastllm.egg-info/SOURCES.txt +0 -0
  25. {python_fastllm-0.0.12 → python_fastllm-0.0.15}/python_fastllm.egg-info/dependency_links.txt +0 -0
  26. {python_fastllm-0.0.12 → python_fastllm-0.0.15}/python_fastllm.egg-info/entry_points.txt +0 -0
  27. {python_fastllm-0.0.12 → python_fastllm-0.0.15}/python_fastllm.egg-info/top_level.txt +0 -0
  28. {python_fastllm-0.0.12 → python_fastllm-0.0.15}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-fastllm
3
- Version: 0.0.12
3
+ Version: 0.0.15
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
@@ -11,6 +11,7 @@ Requires-Python: >=3.10
11
11
  Description-Content-Type: text/markdown
12
12
  Requires-Dist: fastspec
13
13
  Requires-Dist: toolslm
14
+ Requires-Dist: pillow
14
15
 
15
16
  # fastllm
16
17
 
@@ -0,0 +1 @@
1
+ __version__ = "0.0.15"
@@ -19,6 +19,7 @@ d = { 'settings': { 'branch': 'main',
19
19
  'fastllm.acomplete.mk_client': ('acomplete.html#mk_client', 'fastllm/acomplete.py')},
20
20
  'fastllm.anthropic': { 'fastllm.anthropic._ant_cc': ('anthropic.html#_ant_cc', 'fastllm/anthropic.py'),
21
21
  'fastllm.anthropic._ant_part_type': ('anthropic.html#_ant_part_type', 'fastllm/anthropic.py'),
22
+ 'fastllm.anthropic._sanid': ('anthropic.html#_sanid', 'fastllm/anthropic.py'),
22
23
  'fastllm.anthropic.acollect_stream': ('anthropic.html#acollect_stream', 'fastllm/anthropic.py'),
23
24
  'fastllm.anthropic.cost': ('anthropic.html#cost', 'fastllm/anthropic.py'),
24
25
  'fastllm.anthropic.delta_index_fn': ('anthropic.html#delta_index_fn', 'fastllm/anthropic.py'),
@@ -204,7 +205,8 @@ d = { 'settings': { 'branch': 'main',
204
205
  'fastllm.openai_chat.norm_parts': ('oai_chat.html#norm_parts', 'fastllm/openai_chat.py'),
205
206
  'fastllm.openai_chat.norm_sse_event': ('oai_chat.html#norm_sse_event', 'fastllm/openai_chat.py'),
206
207
  'fastllm.openai_chat.norm_tool_calls': ('oai_chat.html#norm_tool_calls', 'fastllm/openai_chat.py')},
207
- 'fastllm.openai_responses': { 'fastllm.openai_responses.acollect_stream': ( 'oai_responses.html#acollect_stream',
208
+ 'fastllm.openai_responses': { 'fastllm.openai_responses._sanid': ('oai_responses.html#_sanid', 'fastllm/openai_responses.py'),
209
+ 'fastllm.openai_responses.acollect_stream': ( 'oai_responses.html#acollect_stream',
208
210
  'fastllm/openai_responses.py'),
209
211
  'fastllm.openai_responses.cost': ('oai_responses.html#cost', 'fastllm/openai_responses.py'),
210
212
  'fastllm.openai_responses.delta_index_fn': ( 'oai_responses.html#delta_index_fn',
@@ -291,5 +293,6 @@ d = { 'settings': { 'branch': 'main',
291
293
  'fastllm.types.part_txt': ('types.html#part_txt', 'fastllm/types.py'),
292
294
  'fastllm.types.payload_kwargs': ('types.html#payload_kwargs', 'fastllm/types.py'),
293
295
  'fastllm.types.register_model_info': ('types.html#register_model_info', 'fastllm/types.py'),
296
+ 'fastllm.types.resize_b64': ('types.html#resize_b64', 'fastllm/types.py'),
294
297
  'fastllm.types.sys_text': ('types.html#sys_text', 'fastllm/types.py'),
295
298
  'fastllm.types.url_mime': ('types.html#url_mime', 'fastllm/types.py')}}}
@@ -85,6 +85,7 @@ def norm_parts(resp):
85
85
 
86
86
  # %% ../nbs/04_anthropic.ipynb #a3869e31
87
87
  def norm_sse_event(ev, **kwargs):
88
+ ev = obj2dict(ev)
88
89
  typ = ev.get("type")
89
90
  text, thinking, tcs, citations = None, None, [], None
90
91
  if typ == "content_block_start":
@@ -125,11 +126,14 @@ def _ant_cc(block, p):
125
126
  if (cc := (p.data or {}).get('cache_control')): block['cache_control'] = cc
126
127
  return block
127
128
 
129
+ # %% ../nbs/04_anthropic.ipynb #62e9a042
130
+ def _sanid(id_str): return re.sub(r'[^a-zA-Z0-9_-]', '_', id_str or '')
131
+
128
132
  # %% ../nbs/04_anthropic.ipynb #6ec772cb
129
133
  def denorm_tool_use(p:Part):
130
134
  "Convert canonical tool_use Part to Anthropic tool_use content block."
131
135
  d = p.data or {}
132
- block = dict(type='tool_use', id=d.get('id',''), name=d.get('name',''), input=d.get('arguments') or {})
136
+ block = dict(type='tool_use', id=_sanid(d.get('id','')), name=d.get('name',''), input=d.get('arguments') or {})
133
137
  if 'caller' in d: block['caller'] = d['caller']
134
138
  return _ant_cc(block, p)
135
139
 
@@ -238,8 +242,10 @@ def denorm_user(m:Msg):
238
242
  return dict(role='user', content=parts)
239
243
 
240
244
  # %% ../nbs/04_anthropic.ipynb #edd87272
241
- def denorm_image(p):
242
- if (b64:=data_url(p.text)): return {"type": "image", "source": {"type": "base64", "media_type": b64[0], "data": b64[1]}}
245
+ def denorm_image(p, max_sz=None):
246
+ if (b64:=data_url(p.text)):
247
+ data = resize_b64(b64[1], max_sz) if max_sz else b64[1]
248
+ return {"type": "image", "source": {"type": "base64", "media_type": b64[0], "data": data}}
243
249
  return {"type": "image", "source": {"type": "url", "url": p.text}}
244
250
 
245
251
  # %% ../nbs/04_anthropic.ipynb #fc6bbdfc
@@ -251,12 +257,12 @@ def denorm_file(p):
251
257
  def denorm_tool_result(p:Part):
252
258
  "Convert canonical tool_result Part to Anthropic tool_result content block."
253
259
  d = p.data or {}
254
- tid = d.get('id') or d.get('call_id','')
260
+ tid = _sanid(d.get('id') or d.get('call_id',''))
255
261
  if isinstance(p.text, list):
256
262
  blocks = []
257
263
  for pp in p.text:
258
264
  if pp.type == PartType.text: blocks.append({"type": "text", "text": pp.text or ""})
259
- elif pp.type == PartType.input_image: blocks.append(denorm_image(pp))
265
+ elif pp.type == PartType.input_image: blocks.append(denorm_image(pp, max_sz=2000))
260
266
  elif pp.type == PartType.input_file: blocks.append(denorm_file(pp))
261
267
  else: raise ValueError(f"Anthropic tool_result does not support {pp.type}")
262
268
  return _ant_cc(dict(type='tool_result', tool_use_id=tid, content=blocks), p)
@@ -183,7 +183,7 @@ def fmt2hist(outp:str)->list[Msg]:
183
183
  "Transform a formatted output string into fastllm canonical Msgs"
184
184
  if token_dtls_tag in outp: outp = re_token.sub('', outp)
185
185
  if tool_dtls_tag not in outp:
186
- msg = Msg(role='assistant', content=[Part(type=PartType.text, text=outp.strip())])
186
+ msg = Msg(role='assistant', content=[Part(type=PartType.text, text=outp.strip() or '.')])
187
187
  return _split_msg_on_fences(msg)
188
188
  hist, asst_parts, tool_parts = [], [], []
189
189
  def flush():
@@ -194,7 +194,7 @@ def fmt2hist(outp:str)->list[Msg]:
194
194
  for txt,_,tj in split_tools(outp):
195
195
  if txt and txt.strip():
196
196
  if tool_parts: flush()
197
- asst_parts.append(Part(type=PartType.text, text=txt.strip()))
197
+ asst_parts.append(Part(type=PartType.text, text=txt.strip() or '.'))
198
198
  if tj and (tp := _extract_tool_parts(tj)):
199
199
  asst_parts.append(tp[0])
200
200
  tool_parts.append(tp[1])
@@ -316,7 +316,7 @@ def _has_stop(tres_parts): return any(isinstance(p.text, StopResponse) for p in
316
316
  def _trunc_str(s, mx=2000, skip=10, replace="TRUNCATED"):
317
317
  "Truncate `s` to `mx` chars max, adding `replace` if truncated"
318
318
  if not isinstance(s, str): s = str(s)
319
- s = s.rstrip()
319
+ s = type(s)(s.rstrip())
320
320
  if len(s)>2 and s[0]=='𝍁' and s[-1]=='𝍁':
321
321
  s = s[1:-1]
322
322
  if replace: return s
@@ -86,6 +86,7 @@ def norm_parts(resp):
86
86
  # %% ../nbs/05_gemini.ipynb #9a5024ee
87
87
  def norm_sse_event(ev, **kwargs):
88
88
  "Normalize Gemini stream event into Delta."
89
+ ev = obj2dict(ev)
89
90
  cand = nested_idx(ev, 'candidates', 0) or {}
90
91
  finish_reason = norm_finish(ev)
91
92
  parts = nested_idx(cand, 'content', 'parts') or []
@@ -55,6 +55,7 @@ def norm_parts(resp):
55
55
  def norm_sse_event(ev, **kwargs):
56
56
  "Normalize a chat completion stream event."
57
57
  # usage always arrives as a single final event with choices: []
58
+ ev = obj2dict(ev)
58
59
  fin = nested_idx(ev, 'choices', 0, 'finish_reason')
59
60
  tcs = norm_tool_calls(ev, delta=True)
60
61
  if (dlt:=nested_idx(ev, 'choices', 0, 'delta')) is not None:
@@ -82,6 +82,7 @@ def norm_parts(resp):
82
82
  # %% ../nbs/02_oai_responses.ipynb #7cd48aa5
83
83
  def norm_sse_event(ev, **kwargs):
84
84
  "Normalize OpenAI Responses API stream event into Delta."
85
+ ev = obj2dict(ev)
85
86
  typ = ev.get("type")
86
87
  if typ == "response.output_text.delta": return Delta(text=ev.get("delta"), raw=ev, **kwargs)
87
88
  if typ == "response.reasoning_text.delta": return Delta(thinking=ev.get("delta",""), raw=ev, **kwargs)
@@ -109,10 +110,13 @@ async def acollect_stream(resp, **kwargs):
109
110
  res = mk_acollect_stream(norm_and_yield(resp, norm_sse_event), index_fn=delta_index_fn, api_name='openai', **kwargs)
110
111
  async for o in res: yield o
111
112
 
113
+ # %% ../nbs/02_oai_responses.ipynb #9608e813
114
+ def _sanid(id_str): return id_str[:64] # codex max 64 char limit
115
+
112
116
  # %% ../nbs/02_oai_responses.ipynb #b746c82b
113
117
  def denorm_tool_use(p:Part):
114
118
  "Convert canonical tool_use Part back to OpenAI Responses function_call item."
115
- return dict(type='function_call', call_id=p.data.get('id'), name=p.data.get('name'), arguments=json.dumps(p.data.get('arguments', '{}')))
119
+ return dict(type='function_call', call_id=_sanid(p.data.get('id')), name=p.data.get('name'), arguments=json.dumps(p.data.get('arguments', '{}')))
116
120
 
117
121
  # %% ../nbs/02_oai_responses.ipynb #8f42adf7
118
122
  def denorm_tool(m:Msg):
@@ -208,7 +212,7 @@ def denorm_file(p):
208
212
  # %% ../nbs/02_oai_responses.ipynb #145b1c79
209
213
  def denorm_tool_result(m:Part):
210
214
  "Convert canonical tool result back to OpenAI Responses function_call_output item."
211
- cid = m.data.get('id', '') or m.data.get('call_id')
215
+ cid = _sanid(m.data.get('id', '') or m.data.get('call_id'))
212
216
  if isinstance(m.text, list):
213
217
  out = []
214
218
  for p in m.text:
@@ -137,14 +137,10 @@ async def mk_acollect_stream(it, index_fn, model=None, api_name=None, vendor_nam
137
137
  data = {**acc.extra, 'id':acc.id, 'name':acc.name, 'arguments':args, 'server':acc.server}
138
138
  yield Part(type=PartType.tool_use, data=data)
139
139
  # Server tool results for anthropic are yielded in d.server_tool_result by checking injected dummy `_delta`
140
- if acc.server and '_delta' not in tc.arguments: yield Part(type=PartType.tool_result, text="Server tool call executed.", data=data)
140
+ if acc.server: yield Part(type=PartType.tool_result, text="Server tool call executed.", data=data)
141
141
  if d.server_tool_result:
142
142
  idx = _fidx(d, 'server_tool_result')
143
143
  part_accum.parts[idx] = Part(type=typ, data=d.server_tool_result)
144
- srv_tc = next((p for p in reversed(list(part_accum.parts.values())) if isinstance(p, ToolCall) and p.server), None)
145
- if srv_tc:
146
- data = {**srv_tc.extra, 'id':srv_tc.id, 'name':srv_tc.name, 'arguments':srv_tc.arguments, 'server':True}
147
- yield Part(type=PartType.tool_result, text="Server tool call executed.", data=data)
148
144
  r = _proc(d, 'refusal')
149
145
  if r[0]: yield r[0]
150
146
  if d.finish_reason: fin = d.finish_reason
@@ -7,15 +7,16 @@ __all__ = ['PartType', 'FinishReason', 'api_registry', 'model_prices_url', 'haik
7
7
  'gpt54', 'gpt54m', 'gpt55', 'codex54', 'codex54m', 'codex55', 'codex53spark', 'model_info_registry',
8
8
  'modern_llm', 'deepseek_v4_common', 'mimo_v25_common', 'codex_pricing', 'Part', 'Msg', 'ToolCall',
9
9
  'display_list', 'Usage', 'Completion', 'APIRegistry', 'mk_completion', 'mk_tool_res_msg', 'fn_schema',
10
- 'sys_text', 'part_txt', 'data_url', 'url_mime', 'payload_kwargs', 'get_api_key', 'model_prices_meta',
11
- 'infer_api_name', 'get_model_meta', 'register_model_info', 'get_model_info', 'get_model_pricing',
12
- 'approx_pricing']
10
+ 'sys_text', 'part_txt', 'data_url', 'url_mime', 'payload_kwargs', 'get_api_key', 'resize_b64',
11
+ 'model_prices_meta', 'infer_api_name', 'get_model_meta', 'register_model_info', 'get_model_info',
12
+ 'get_model_pricing', 'approx_pricing']
13
13
 
14
14
  # %% ../nbs/00_types.ipynb #b4d047fd
15
- import httpx
15
+ import httpx, base64, io
16
16
  from dataclasses import dataclass, field
17
17
  from fastcore.net import urljson
18
18
  from fastcore.utils import *
19
+ from PIL import Image as PImg
19
20
 
20
21
  # %% ../nbs/00_types.ipynb #e568bade
21
22
  @dataclass
@@ -160,6 +161,7 @@ api_registry = APIRegistry()
160
161
  # %% ../nbs/00_types.ipynb #d58a5f96
161
162
  def mk_completion(resp, model, api_name, vendor_name):
162
163
  "Normalize an api response into Completion."
164
+ resp = obj2dict(resp)
163
165
  api = api_registry.apis[api_name]
164
166
  tcs = api.norm_tool_calls(resp)
165
167
  parts = api.norm_parts(resp)
@@ -242,11 +244,26 @@ def get_api_key(api_key, default):
242
244
  if not key: raise ValueError(f"Missing API key: set environment variable '{default}' or pass `api_key` parameter")
243
245
  return key
244
246
 
247
+ # %% ../nbs/00_types.ipynb #25e9cd60
248
+ def resize_b64(b64, max_sz):
249
+ "Resize a base64 image data to a max long edge, preserving aspect ratio."
250
+ img = PImg.open(io.BytesIO(base64.b64decode(b64)))
251
+ if max(img.size) < max_sz: return b64
252
+ img.thumbnail((max_sz,max_sz), PImg.Resampling.LANCZOS)
253
+ fmt = (img.format or 'PNG').upper()
254
+ if fmt=='JPG': fmt = 'JPEG'
255
+ buf = io.BytesIO()
256
+ img.save(buf, format=fmt)
257
+ return base64.b64encode(buf.getvalue()).decode()
258
+
245
259
  # %% ../nbs/00_types.ipynb #852adecd
246
260
  model_prices_url = 'https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json'
247
261
 
248
- @flexicache(time_policy(24*60*60))
249
- def model_prices_meta(): return urljson(model_prices_url)
262
+ def model_prices_meta():
263
+ "Download model prices to the module dir once, then load from disk."
264
+ p = Path(__file__).parent/'model_prices.json'
265
+ if not p.exists(): p.write_text(httpx.get(model_prices_url, follow_redirects=True).text)
266
+ return loads(p.read_text())
250
267
 
251
268
  # %% ../nbs/00_types.ipynb #68e488d8
252
269
  def infer_api_name(model):
@@ -256,7 +273,6 @@ def infer_api_name(model):
256
273
  if any(o in model for o in ('gpt','o3-','o4-')): return 'openai'
257
274
 
258
275
  # %% ../nbs/00_types.ipynb #2f0720c2
259
- @flexicache(time_policy(24*60*60))
260
276
  def get_model_meta(model, vendor_name=None, tfm=noop):
261
277
  "Look up cost metadata for `model` from litellm price map, using `vendor_name` prefix if needed."
262
278
  vendor_name = ifnone(vendor_name, infer_api_name(model))
@@ -264,7 +280,7 @@ def get_model_meta(model, vendor_name=None, tfm=noop):
264
280
  if model in mp: key = model
265
281
  elif vendor_name=='gemini' and model.startswith('models/'): key = f"gemini/{model.removeprefix('models/')}"
266
282
  elif vendor_name: key = f"{vendor_name}/{model}"
267
- return dict2obj(tfm(mp.get(key), model, vendor_name))
283
+ return dict2obj(tfm(mp.get(key, {}), model, vendor_name))
268
284
 
269
285
  # %% ../nbs/00_types.ipynb #60607e23
270
286
  haik45 = "claude-haiku-4-5"
@@ -342,9 +358,9 @@ register_model_info('deepseek-v4-pro', vendor_name='deepseek', base='deepseek/de
342
358
 
343
359
  mimo_v25_common = dict(**modern_llm, supports_web_search=True, max_input_tokens=1048576, max_output_tokens=131072, max_tokens=131072)
344
360
 
345
- register_model_info('mimo-v2.5-pro', vendor_name='mimo', **mimo_v25_common, base='deepseek/deepseek-v4-pro',
361
+ register_model_info('mimo-v2.5-pro', vendor_name='mimo', **mimo_v25_common, base='deepseek-v4-pro', base_vendor_name='deepseek',
346
362
  input_cost_per_token=0.435e-6, output_cost_per_token=0.87e-6, cache_read_input_token_cost=0.0036e-6, search_context_cost_per_query=0.005)
347
- register_model_info('mimo-v2.5', vendor_name='mimo', **mimo_v25_common, base='deepseek/deepseek-v4',
363
+ register_model_info('mimo-v2.5', vendor_name='mimo', **mimo_v25_common, base='deepseek-v4-pro', base_vendor_name='deepseek',
348
364
  input_cost_per_token=0.14e-6, output_cost_per_token=0.28e-6, cache_read_input_token_cost=0.0028e-6, search_context_cost_per_query=0.005,
349
365
  supports_vision=True, supports_image_input=True)
350
366
 
@@ -15,7 +15,7 @@ classifiers = [
15
15
  "Programming Language :: Python :: 3",
16
16
  "Programming Language :: Python :: 3 :: Only",
17
17
  ]
18
- dependencies = ['fastspec', 'toolslm']
18
+ dependencies = ['fastspec', 'toolslm', 'pillow']
19
19
 
20
20
  [project.urls]
21
21
  Repository = "https://github.com/AnswerDotAI/fastllm"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-fastllm
3
- Version: 0.0.12
3
+ Version: 0.0.15
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
@@ -11,6 +11,7 @@ Requires-Python: >=3.10
11
11
  Description-Content-Type: text/markdown
12
12
  Requires-Dist: fastspec
13
13
  Requires-Dist: toolslm
14
+ Requires-Dist: pillow
14
15
 
15
16
  # fastllm
16
17
 
@@ -1 +0,0 @@
1
- __version__ = "0.0.12"