@octocodeai/freellama-mcp-server 0.1.0 → 0.5.0
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.
- package/LICENSE-APACHE +202 -0
- package/LICENSE-MIT +18 -0
- package/README.md +203 -33
- package/adapters/agent_context.py +216 -29
- package/adapters/agent_transport.py +131 -0
- package/adapters/bash_agent.py +67 -92
- package/adapters/octocode_agent.py +63 -53
- package/adapters/shell_sandbox.py +487 -0
- package/assets/logo.jpg +0 -0
- package/dist/index.js +1287 -412
- package/docs/ARCHITECTURE.md +169 -26
- package/docs/CLI.md +107 -17
- package/docs/CPU_GPU_ROUTING.md +65 -20
- package/docs/ECONOMICS.md +20 -15
- package/docs/INDEX.md +8 -2
- package/docs/MODEL_METADATA.md +106 -0
- package/docs/MODEL_SELECTION.md +15 -2
- package/docs/MONITORING.md +285 -0
- package/docs/OLLAMA_SIDECAR.md +8 -6
- package/docs/PRODUCTION.md +49 -18
- package/docs/PRODUCT_POSITIONING.md +86 -256
- package/docs/README.md +40 -0
- package/docs/SCOPES_AND_WARMING.md +226 -0
- package/docs/TESTING.md +118 -1
- package/docs/dev/FINDINGS_AND_POSITIONING.md +290 -0
- package/docs/dev/LOCAL_PERFORMANCE_TESTING.md +298 -0
- package/docs/{OLLAMA_SYSTEM_OPTIMIZATION.md → dev/OLLAMA_SYSTEM_OPTIMIZATION.md} +19 -18
- package/native/index.d.ts +31 -0
- package/package.json +13 -10
- /package/docs/{ADR_RESOURCE_AWARE_BACKEND_ROUTING.md → dev/ADR_RESOURCE_AWARE_BACKEND_ROUTING.md} +0 -0
|
@@ -20,6 +20,9 @@ import json
|
|
|
20
20
|
import hashlib
|
|
21
21
|
import math
|
|
22
22
|
import os
|
|
23
|
+
import re
|
|
24
|
+
import struct
|
|
25
|
+
import tempfile
|
|
23
26
|
import time
|
|
24
27
|
from dataclasses import asdict, dataclass
|
|
25
28
|
from pathlib import Path
|
|
@@ -47,6 +50,10 @@ COMPACT_RETAIN_RATIO = 0.8
|
|
|
47
50
|
CLIP_HEAD_RATIO = 2 / 3
|
|
48
51
|
# Full observations are kept outside the prompt and served in line-aware pages of this size.
|
|
49
52
|
OBSERVATION_PAGE_CHARS = 3000
|
|
53
|
+
# Once an overflow requires changing old observations, reclaim modest headroom in the same pass.
|
|
54
|
+
# This is a target after overflow, not an early trigger; already-fitting prefixes stay untouched.
|
|
55
|
+
COMPACTION_TARGET_RATIO = 0.9
|
|
56
|
+
COMPACT_MARKER = "[earlier observation compacted to save context"
|
|
50
57
|
|
|
51
58
|
|
|
52
59
|
def _env_int(env: Mapping[str, str], name: str, default: int, *, minimum: int = 0) -> int:
|
|
@@ -224,7 +231,7 @@ class ContextBudgeter:
|
|
|
224
231
|
|
|
225
232
|
Ollama currently has no stable preflight tokenizer endpoint. The first call therefore uses the
|
|
226
233
|
configured character estimate. After every successful call, `prompt_eval_count` raises (never
|
|
227
|
-
lowers) the estimate multiplier
|
|
234
|
+
lowers) the estimate multiplier. A model tag alone does not identify an immutable template.
|
|
228
235
|
"""
|
|
229
236
|
|
|
230
237
|
def __init__(
|
|
@@ -298,8 +305,45 @@ def context_budget(
|
|
|
298
305
|
|
|
299
306
|
|
|
300
307
|
def _compact(content: str, policy: ContextPolicy) -> str:
|
|
301
|
-
|
|
302
|
-
|
|
308
|
+
"""Keep a deterministic evidence breadcrumb and a usable stored-output handle.
|
|
309
|
+
|
|
310
|
+
This is an extract, not a semantic summary: no inferred facts or decisions are added. Existing
|
|
311
|
+
breadcrumbs remain byte-identical so later overflows do not invalidate the prefix again.
|
|
312
|
+
"""
|
|
313
|
+
if content.startswith(COMPACT_MARKER):
|
|
314
|
+
return content
|
|
315
|
+
step_match = re.match(r"Observation \(step ([0-9]+)\):\n", content)
|
|
316
|
+
recovery = ""
|
|
317
|
+
body = content
|
|
318
|
+
if step_match:
|
|
319
|
+
step = int(step_match.group(1))
|
|
320
|
+
body = content[step_match.end():]
|
|
321
|
+
# Adapter-generated ledger precedes untrusted tool bytes. Preserve its concrete handles
|
|
322
|
+
# and status; this never promotes a tool result into instructions or inferred facts.
|
|
323
|
+
ledger_line, separator, remainder = body.partition("\n")
|
|
324
|
+
ledger = ledger_line if ledger_line.startswith("[evidence ledger: ") and ledger_line.endswith("]") else ""
|
|
325
|
+
if ledger and separator:
|
|
326
|
+
body = remainder
|
|
327
|
+
# Adapter loop reminders describe the old turn, not the evidence. The latest turn retains
|
|
328
|
+
# its live reminder; exclude this known suffix from the tail extract of older results.
|
|
329
|
+
body = re.sub(
|
|
330
|
+
r"\n\n(?:Commands|Tool calls) remaining: [0-9]+\. Finish now if the task is answerable; "
|
|
331
|
+
r"do not repeat prior (?:commands|calls)\.$", "", body,
|
|
332
|
+
)
|
|
333
|
+
# Read only the footer shape generated by page_footer, preserving page completeness.
|
|
334
|
+
footer_match = re.search(r"\n\n\[page ([0-9]+) of ([0-9]+) — [0-9]+ characters total, nothing discarded\. ", body)
|
|
335
|
+
page = int(footer_match.group(1)) if footer_match else 1
|
|
336
|
+
total = int(footer_match.group(2)) if footer_match else 1
|
|
337
|
+
if footer_match:
|
|
338
|
+
body = body[:footer_match.start()]
|
|
339
|
+
recovery = (
|
|
340
|
+
f'; step {step}, page {page} of {total}; retrieve stored output: '
|
|
341
|
+
f'{{"action":"page","step":{step},"page":{page}}}'
|
|
342
|
+
)
|
|
343
|
+
if ledger:
|
|
344
|
+
recovery += f"; {ledger}"
|
|
345
|
+
preview = clip(body, policy.compact_preview_chars, policy.clip_head_ratio)
|
|
346
|
+
return f"{COMPACT_MARKER}{recovery}] {preview}"
|
|
303
347
|
|
|
304
348
|
|
|
305
349
|
def fit_to_context(
|
|
@@ -338,8 +382,9 @@ def fit_to_context(
|
|
|
338
382
|
]
|
|
339
383
|
protected = set(observations[-keep_recent:]) if keep_recent else set()
|
|
340
384
|
compacted = False
|
|
385
|
+
target = math.floor(budget * COMPACTION_TARGET_RATIO)
|
|
341
386
|
for index in observations:
|
|
342
|
-
if count(trimmed) <=
|
|
387
|
+
if count(trimmed) <= target:
|
|
343
388
|
break
|
|
344
389
|
if index in protected:
|
|
345
390
|
continue
|
|
@@ -439,10 +484,12 @@ class AgentContextManager:
|
|
|
439
484
|
*,
|
|
440
485
|
model: str = "",
|
|
441
486
|
calibration_dir: Path | None = None,
|
|
487
|
+
calibration_identity: str = "",
|
|
442
488
|
) -> None:
|
|
443
489
|
self.runtime = runtime
|
|
444
490
|
self.model = model
|
|
445
491
|
self.calibration_dir = calibration_dir
|
|
492
|
+
self.calibration_identity = calibration_identity
|
|
446
493
|
loaded = self._load_calibration()
|
|
447
494
|
self.budgeter = ContextBudgeter(
|
|
448
495
|
runtime.context,
|
|
@@ -453,7 +500,17 @@ class AgentContextManager:
|
|
|
453
500
|
self.compactions = 0
|
|
454
501
|
|
|
455
502
|
def _model_key(self) -> str:
|
|
456
|
-
return hashlib.sha256(self.
|
|
503
|
+
return hashlib.sha256(json.dumps(self._calibration_contract(), sort_keys=True).encode("utf-8")).hexdigest()[:24]
|
|
504
|
+
|
|
505
|
+
def _calibration_contract(self) -> dict[str, Any]:
|
|
506
|
+
return {
|
|
507
|
+
"schema_version": 2,
|
|
508
|
+
"model": self.model,
|
|
509
|
+
"identity": self.calibration_identity,
|
|
510
|
+
"estimator_version": 1,
|
|
511
|
+
"chars_per_token": self.runtime.context.chars_per_token,
|
|
512
|
+
"image_token_estimate": self.runtime.context.image_token_estimate,
|
|
513
|
+
}
|
|
457
514
|
|
|
458
515
|
def _load_calibration(self) -> dict[str, Any]:
|
|
459
516
|
if not self.model or self.calibration_dir is None or not self.calibration_dir.exists():
|
|
@@ -463,11 +520,17 @@ class AgentContextManager:
|
|
|
463
520
|
for path in self.calibration_dir.glob(f"{self._model_key()}-*.json"):
|
|
464
521
|
try:
|
|
465
522
|
record = json.loads(path.read_text(encoding="utf-8"))
|
|
466
|
-
if record
|
|
523
|
+
if not isinstance(record, dict):
|
|
524
|
+
continue
|
|
525
|
+
if any(record.get(key) != value for key, value in self._calibration_contract().items()):
|
|
526
|
+
continue
|
|
527
|
+
scale = float(record.get("scale", 1.0))
|
|
528
|
+
record_samples = int(record.get("samples", 0))
|
|
529
|
+
if not math.isfinite(scale) or scale < 1 or record_samples <= 0:
|
|
467
530
|
continue
|
|
468
|
-
best_scale = max(best_scale,
|
|
469
|
-
samples = max(samples,
|
|
470
|
-
except (OSError, ValueError, TypeError,
|
|
531
|
+
best_scale = max(best_scale, scale)
|
|
532
|
+
samples = max(samples, record_samples)
|
|
533
|
+
except (OSError, ValueError, TypeError, OverflowError):
|
|
471
534
|
continue
|
|
472
535
|
return {"scale": best_scale, "samples": samples} if samples else {}
|
|
473
536
|
|
|
@@ -477,8 +540,7 @@ class AgentContextManager:
|
|
|
477
540
|
self.calibration_dir.mkdir(parents=True, exist_ok=True)
|
|
478
541
|
key = self._model_key()
|
|
479
542
|
record = {
|
|
480
|
-
|
|
481
|
-
"model": self.model,
|
|
543
|
+
**self._calibration_contract(),
|
|
482
544
|
"scale": self.budgeter.scale,
|
|
483
545
|
"samples": self.budgeter.calibration_samples,
|
|
484
546
|
}
|
|
@@ -515,6 +577,8 @@ class AgentContextManager:
|
|
|
515
577
|
**self.runtime.context.metadata(),
|
|
516
578
|
**self.budgeter.metadata(),
|
|
517
579
|
"calibration_source": self.calibration_source,
|
|
580
|
+
"calibration_scope": "provided_identity_and_estimator" if self.calibration_identity else "model_tag_and_estimator",
|
|
581
|
+
"compaction_target_ratio": COMPACTION_TARGET_RATIO,
|
|
518
582
|
"estimated_input_budget": context_budget(
|
|
519
583
|
self.runtime.num_ctx,
|
|
520
584
|
self.runtime.num_predict,
|
|
@@ -598,24 +662,31 @@ PARSE_REPAIR_NOTICE = (
|
|
|
598
662
|
# ---------------------------------------------------------------------------------------------
|
|
599
663
|
|
|
600
664
|
def paginate(text: str, page: int = 1, page_size: int = OBSERVATION_PAGE_CHARS) -> tuple[str, int, int]:
|
|
601
|
-
"""Return `(chunk, page, total_pages)`,
|
|
665
|
+
"""Return `(chunk, page, total_pages)`, preferring line boundaries within a hard limit.
|
|
602
666
|
|
|
603
667
|
Line-aware on purpose: these observations are grep hits, directory listings and file slices,
|
|
604
668
|
where a chunk cut mid-line yields a truncated path or a half identifier — exactly the kind of
|
|
605
|
-
fragment that gets misread as data rather than as damage.
|
|
669
|
+
fragment that gets misread as data rather than as damage. An oversized individual line is
|
|
670
|
+
split to keep even minified JSON/logs bounded; joining all chunks reproduces the exact input.
|
|
606
671
|
"""
|
|
672
|
+
if page_size <= 0:
|
|
673
|
+
raise ValueError("observation page size must be > 0")
|
|
607
674
|
if not text:
|
|
608
675
|
return "", 1, 1
|
|
609
676
|
lines = text.splitlines(keepends=True)
|
|
610
677
|
pages: list[list[str]] = [[]]
|
|
611
678
|
size = 0
|
|
612
679
|
for line in lines:
|
|
613
|
-
# A single line longer than a page gets its own page rather than being split.
|
|
614
680
|
if size and size + len(line) > page_size:
|
|
615
681
|
pages.append([])
|
|
616
682
|
size = 0
|
|
617
|
-
|
|
618
|
-
|
|
683
|
+
for start in range(0, len(line), page_size):
|
|
684
|
+
part = line[start:start + page_size]
|
|
685
|
+
if size and size + len(part) > page_size:
|
|
686
|
+
pages.append([])
|
|
687
|
+
size = 0
|
|
688
|
+
pages[-1].append(part)
|
|
689
|
+
size += len(part)
|
|
619
690
|
total = max(1, len(pages))
|
|
620
691
|
page = max(1, min(page, total))
|
|
621
692
|
return "".join(pages[page - 1]), page, total
|
|
@@ -625,8 +696,12 @@ def page_footer(step: int, page: int, total: int, total_chars: int) -> str:
|
|
|
625
696
|
"""Tell the model exactly how to get the rest — or that there is no rest."""
|
|
626
697
|
if total <= 1:
|
|
627
698
|
return ""
|
|
628
|
-
|
|
629
|
-
|
|
699
|
+
if page >= total:
|
|
700
|
+
return (
|
|
701
|
+
f'\n\n[page {page} of {total} — {total_chars} characters total, nothing discarded. '
|
|
702
|
+
'End of stored output.]'
|
|
703
|
+
)
|
|
704
|
+
nxt = page + 1
|
|
630
705
|
return (
|
|
631
706
|
f'\n\n[page {page} of {total} — {total_chars} characters total, nothing discarded. '
|
|
632
707
|
f'For the next page send exactly: {{"action":"page","step":{step},"page":{nxt}}} '
|
|
@@ -634,30 +709,142 @@ def page_footer(step: int, page: int, total: int, total_chars: int) -> str:
|
|
|
634
709
|
)
|
|
635
710
|
|
|
636
711
|
|
|
712
|
+
@dataclass(frozen=True)
|
|
713
|
+
class StoredObservation:
|
|
714
|
+
path: Path
|
|
715
|
+
index_path: Path
|
|
716
|
+
characters: int
|
|
717
|
+
pages: int
|
|
718
|
+
metadata: dict[str, str]
|
|
719
|
+
|
|
720
|
+
|
|
637
721
|
class ObservationStore:
|
|
638
|
-
"""
|
|
722
|
+
"""Disk-backed exact observations with one small descriptor per step in memory.
|
|
639
723
|
|
|
640
|
-
|
|
641
|
-
|
|
724
|
+
Page offsets also live on disk; an arbitrary page read loads one fixed-width offset record
|
|
725
|
+
and one page. The subprocess result exists transiently at put(), never across model turns.
|
|
642
726
|
"""
|
|
643
727
|
|
|
644
728
|
def __init__(self, page_size: int = OBSERVATION_PAGE_CHARS) -> None:
|
|
645
729
|
if page_size <= 0:
|
|
646
730
|
raise ValueError("observation page size must be > 0")
|
|
647
|
-
self.
|
|
731
|
+
self._temporary = tempfile.TemporaryDirectory(prefix="freellama-observations-")
|
|
732
|
+
self.directory = Path(self._temporary.name)
|
|
733
|
+
self._by_step: dict[int, StoredObservation] = {}
|
|
648
734
|
self.page_size = page_size
|
|
649
735
|
|
|
650
|
-
def
|
|
651
|
-
self
|
|
736
|
+
def __enter__(self) -> "ObservationStore":
|
|
737
|
+
return self
|
|
738
|
+
|
|
739
|
+
def __exit__(self, *_: Any) -> None:
|
|
740
|
+
self.close()
|
|
741
|
+
|
|
742
|
+
def close(self) -> None:
|
|
743
|
+
self._by_step.clear()
|
|
744
|
+
self._temporary.cleanup()
|
|
745
|
+
|
|
746
|
+
def put(self, step: int, text: str, *, metadata: Mapping[str, Any] | None = None) -> None:
|
|
747
|
+
if step < 1:
|
|
748
|
+
raise ValueError("observation step must be positive")
|
|
749
|
+
path = self.directory / f"{step}.utf8"
|
|
750
|
+
index_path = self.directory / f"{step}.index"
|
|
751
|
+
start = offset = pages = 0
|
|
752
|
+
with path.open("wb") as output, index_path.open("wb") as index:
|
|
753
|
+
while start < len(text):
|
|
754
|
+
end = min(start + self.page_size, len(text))
|
|
755
|
+
# Prefer a complete line without allocating or splitting the full result. A line
|
|
756
|
+
# longer than a page is split at the hard character limit, losslessly.
|
|
757
|
+
if end < len(text):
|
|
758
|
+
newline = text.rfind("\n", start, end)
|
|
759
|
+
if newline >= start:
|
|
760
|
+
end = newline + 1
|
|
761
|
+
encoded = text[start:end].encode("utf-8")
|
|
762
|
+
output.write(encoded)
|
|
763
|
+
next_offset = offset + len(encoded)
|
|
764
|
+
index.write(struct.pack(">QQ", offset, next_offset))
|
|
765
|
+
offset = next_offset
|
|
766
|
+
pages += 1
|
|
767
|
+
start = end
|
|
768
|
+
if not pages:
|
|
769
|
+
index.write(struct.pack(">QQ", 0, 0))
|
|
770
|
+
pages = 1
|
|
771
|
+
bounded_metadata = {
|
|
772
|
+
key: clip(str(value), 240)
|
|
773
|
+
for key, value in (metadata or {}).items()
|
|
774
|
+
if key in {"action", "status", "source"}
|
|
775
|
+
}
|
|
776
|
+
self._by_step[step] = StoredObservation(path, index_path, len(text), pages, bounded_metadata)
|
|
652
777
|
|
|
653
778
|
def get(self, step: int) -> str | None:
|
|
654
|
-
|
|
779
|
+
"""Explicit full materialization for consumers that need it; adapters use view/write_result."""
|
|
780
|
+
record = self._by_step.get(step)
|
|
781
|
+
return record.path.read_bytes().decode("utf-8") if record else None
|
|
782
|
+
|
|
783
|
+
def page_count(self, step: int) -> int:
|
|
784
|
+
return self._by_step[step].pages
|
|
655
785
|
|
|
656
786
|
def view(self, step: int, page: int = 1) -> tuple[str, str]:
|
|
657
787
|
"""Return `(body, footer)` for one page of a stored observation."""
|
|
658
|
-
|
|
659
|
-
if
|
|
788
|
+
record = self._by_step.get(step)
|
|
789
|
+
if record is None:
|
|
660
790
|
known = ", ".join(str(k) for k in sorted(self._by_step)) or "none yet"
|
|
661
791
|
return (f"no stored output for step {step} (known steps: {known})", "")
|
|
662
|
-
|
|
663
|
-
|
|
792
|
+
page = max(1, min(page, record.pages))
|
|
793
|
+
with record.index_path.open("rb") as index:
|
|
794
|
+
index.seek((page - 1) * 16)
|
|
795
|
+
start, end = struct.unpack(">QQ", index.read(16))
|
|
796
|
+
with record.path.open("rb") as output:
|
|
797
|
+
output.seek(start)
|
|
798
|
+
chunk = output.read(end - start).decode("utf-8")
|
|
799
|
+
return chunk, page_footer(step, page, record.pages, record.characters)
|
|
800
|
+
|
|
801
|
+
def message(self, step: int, page: int = 1) -> str:
|
|
802
|
+
body, footer = self.view(step, page)
|
|
803
|
+
record = self._by_step.get(step)
|
|
804
|
+
if record is None:
|
|
805
|
+
return f"Observation (step {step}):\n{body}"
|
|
806
|
+
page = max(1, min(page, record.pages))
|
|
807
|
+
ledger = {
|
|
808
|
+
**record.metadata, "step": step, "page": page, "total_pages": record.pages,
|
|
809
|
+
"trust": "untrusted_tool_output",
|
|
810
|
+
}
|
|
811
|
+
return (
|
|
812
|
+
f"Observation (step {step}):\n[evidence ledger: "
|
|
813
|
+
f"{json.dumps(ledger, ensure_ascii=False, separators=(',', ':'))}]\n{body}{footer}"
|
|
814
|
+
)
|
|
815
|
+
|
|
816
|
+
def write_result(self, result_path: Path, result: dict[str, Any]) -> None:
|
|
817
|
+
"""Atomically stream the public JSON result, restoring full call results from disk."""
|
|
818
|
+
result_path.parent.mkdir(parents=True, exist_ok=True)
|
|
819
|
+
temporary: Path | None = None
|
|
820
|
+
try:
|
|
821
|
+
with tempfile.NamedTemporaryFile(
|
|
822
|
+
mode="w", encoding="utf-8", dir=result_path.parent,
|
|
823
|
+
prefix=f".{result_path.name}.", suffix=".tmp", delete=False,
|
|
824
|
+
) as output:
|
|
825
|
+
temporary = Path(output.name)
|
|
826
|
+
output.write("{")
|
|
827
|
+
for field_index, (key, value) in enumerate(result.items()):
|
|
828
|
+
if field_index:
|
|
829
|
+
output.write(",")
|
|
830
|
+
output.write(json.dumps(key) + ":")
|
|
831
|
+
if key != "tool_calls":
|
|
832
|
+
json.dump(value, output, ensure_ascii=False)
|
|
833
|
+
continue
|
|
834
|
+
output.write("[")
|
|
835
|
+
for step, call in enumerate(value, start=1):
|
|
836
|
+
if step > 1:
|
|
837
|
+
output.write(",")
|
|
838
|
+
metadata = {key: value for key, value in call.items() if key != "result"}
|
|
839
|
+
encoded = json.dumps(metadata, ensure_ascii=False)
|
|
840
|
+
output.write(encoded[:-1] + ("," if metadata else "") + '"result":"')
|
|
841
|
+
with self._by_step[step].path.open("r", encoding="utf-8", newline="") as source:
|
|
842
|
+
while chunk := source.read(16_384):
|
|
843
|
+
output.write(json.dumps(chunk, ensure_ascii=False)[1:-1])
|
|
844
|
+
output.write('"}')
|
|
845
|
+
output.write("]")
|
|
846
|
+
output.write("}\n")
|
|
847
|
+
os.replace(temporary, result_path)
|
|
848
|
+
finally:
|
|
849
|
+
if temporary is not None and temporary.exists():
|
|
850
|
+
temporary.unlink()
|
|
@@ -3,8 +3,137 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import os
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
import re
|
|
6
9
|
from pathlib import Path
|
|
7
10
|
from typing import Any
|
|
11
|
+
from urllib.error import HTTPError, URLError
|
|
12
|
+
from urllib.request import Request, urlopen
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _read_identity_json(url: str) -> dict[str, Any]:
|
|
16
|
+
with urlopen(Request(url, headers=request_headers()), timeout=5) as response:
|
|
17
|
+
payload = response.read(1024 * 1024 + 1)
|
|
18
|
+
if len(payload) > 1024 * 1024:
|
|
19
|
+
raise ValueError("model identity catalog exceeds size limit")
|
|
20
|
+
return json.loads(payload)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def resolve_model_identity(
|
|
24
|
+
endpoint: str, model: str, system_prompt: str, *, read_json=None,
|
|
25
|
+
) -> dict[str, Any]:
|
|
26
|
+
"""Resolve immutable manifest identity once per adapter run, without loading a model.
|
|
27
|
+
|
|
28
|
+
Managed catalogs honor exact CPU backend assignment. Failure disables persistent calibration;
|
|
29
|
+
a mutable tag or a hash of the prompt alone must never masquerade as model identity.
|
|
30
|
+
"""
|
|
31
|
+
endpoint = endpoint.rstrip("/")
|
|
32
|
+
managed_suffix = "/_freellama/v1/tasks"
|
|
33
|
+
url = (endpoint[:-len(managed_suffix)] + "/_freellama/v1/models"
|
|
34
|
+
if endpoint.endswith(managed_suffix) else endpoint + "/api/tags")
|
|
35
|
+
unknown = {"identity": "", "verified": False, "scope": "current_process_only"}
|
|
36
|
+
try:
|
|
37
|
+
payload = (read_json or _read_identity_json)(url)
|
|
38
|
+
entries = payload.get("models", []) if isinstance(payload, dict) else []
|
|
39
|
+
if not isinstance(entries, list):
|
|
40
|
+
return unknown
|
|
41
|
+
names = {model}
|
|
42
|
+
if ":" not in model.rsplit("/", 1)[-1]:
|
|
43
|
+
names.add(model + ":latest")
|
|
44
|
+
entry = next((item for item in entries if isinstance(item, dict)
|
|
45
|
+
and item.get("name", item.get("model")) in names), None)
|
|
46
|
+
digest = entry.get("digest") if entry else None
|
|
47
|
+
if not isinstance(digest, str) or not re.fullmatch(r"(?:sha256:)?[0-9a-fA-F]{64}", digest):
|
|
48
|
+
return unknown
|
|
49
|
+
digest = digest.removeprefix("sha256:").lower()
|
|
50
|
+
contract = {"endpoint": endpoint, "model": model, "digest": digest,
|
|
51
|
+
"system_hash": hashlib.sha256(system_prompt.encode("utf-8")).hexdigest()}
|
|
52
|
+
return {"identity": hashlib.sha256(json.dumps(contract, sort_keys=True).encode()).hexdigest(),
|
|
53
|
+
"verified": True, "scope": "manifest_digest_and_system_prompt", "digest": digest}
|
|
54
|
+
except (OSError, ValueError, TypeError):
|
|
55
|
+
return unknown
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def retryable_chat_error(error: Exception) -> bool:
|
|
59
|
+
"""Retry known refusals only; a timeout may leave inference running upstream."""
|
|
60
|
+
if isinstance(error, HTTPError):
|
|
61
|
+
return error.code == 503
|
|
62
|
+
return isinstance(error, URLError) and isinstance(error.reason, ConnectionRefusedError)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def chat_error_details(error: Exception) -> tuple[str, dict[str, Any] | None]:
|
|
66
|
+
"""Retain bounded application refusal receipts, excluding request/auth payloads."""
|
|
67
|
+
diagnostic = f"{type(error).__name__}: {error}"
|
|
68
|
+
if not isinstance(error, HTTPError):
|
|
69
|
+
return diagnostic, None
|
|
70
|
+
details: dict[str, Any] = {"status": error.code}
|
|
71
|
+
try:
|
|
72
|
+
body = error.read(64 * 1024 + 1)
|
|
73
|
+
if len(body) > 64 * 1024:
|
|
74
|
+
return diagnostic, details
|
|
75
|
+
payload = json.loads(body)
|
|
76
|
+
if not isinstance(payload, dict) or not isinstance(payload.get("error"), str):
|
|
77
|
+
return diagnostic, details
|
|
78
|
+
receipt_fields = {
|
|
79
|
+
"error", "code", "reason", "retry_after_seconds", "resource_admission", "lifecycle",
|
|
80
|
+
"placement", "execution", "admission", "route", "decision", "quality", "feedback",
|
|
81
|
+
}
|
|
82
|
+
payload_fields = {
|
|
83
|
+
"prompt", "system", "system_prompt", "messages", "content", "input", "images", "tools",
|
|
84
|
+
"request", "request_options", "headers", "authorization", "token", "api_key", "auth_token",
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
def receipt_value(value: Any) -> Any:
|
|
88
|
+
if isinstance(value, dict):
|
|
89
|
+
return {key: receipt_value(item) for key, item in value.items() if key.lower() not in payload_fields}
|
|
90
|
+
if isinstance(value, list):
|
|
91
|
+
return [receipt_value(item) for item in value]
|
|
92
|
+
return value
|
|
93
|
+
|
|
94
|
+
receipt = {key: receipt_value(value) for key, value in payload.items() if key in receipt_fields}
|
|
95
|
+
details["receipt"] = receipt
|
|
96
|
+
diagnostic = f"HTTP {error.code}: {receipt['error']}"
|
|
97
|
+
except (OSError, ValueError, RecursionError):
|
|
98
|
+
pass
|
|
99
|
+
finally:
|
|
100
|
+
error.close()
|
|
101
|
+
return diagnostic, details
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class PromptCacheUsage:
|
|
105
|
+
"""Aggregate optional Ollama cache reads without treating unavailable turns as zero."""
|
|
106
|
+
|
|
107
|
+
def __init__(self) -> None:
|
|
108
|
+
self.calls = 0
|
|
109
|
+
self.reported_calls = 0
|
|
110
|
+
self.reported_tokens = 0
|
|
111
|
+
self.reported_prompt_tokens = 0
|
|
112
|
+
|
|
113
|
+
def observe(self, response: dict[str, Any]) -> None:
|
|
114
|
+
self.calls += 1
|
|
115
|
+
cached = response.get("prompt_eval_cached_count")
|
|
116
|
+
prompt = response.get("prompt_eval_count")
|
|
117
|
+
if type(cached) is int and type(prompt) is int and 0 <= cached <= prompt:
|
|
118
|
+
self.reported_calls += 1
|
|
119
|
+
self.reported_tokens += cached
|
|
120
|
+
self.reported_prompt_tokens += prompt
|
|
121
|
+
|
|
122
|
+
@property
|
|
123
|
+
def tokens(self) -> int | None:
|
|
124
|
+
return self.reported_tokens if self.calls > 0 and self.calls == self.reported_calls else None
|
|
125
|
+
|
|
126
|
+
def metadata(self) -> dict[str, Any]:
|
|
127
|
+
return {
|
|
128
|
+
"status": "reported" if self.tokens is not None else (
|
|
129
|
+
"partially_reported" if self.reported_calls else "not_reported"
|
|
130
|
+
),
|
|
131
|
+
"calls": self.calls,
|
|
132
|
+
"reported_calls": self.reported_calls,
|
|
133
|
+
"reported_tokens": self.reported_tokens if self.reported_calls else None,
|
|
134
|
+
"hit_ratio": self.reported_tokens / self.reported_prompt_tokens
|
|
135
|
+
if self.tokens is not None and self.reported_prompt_tokens else None,
|
|
136
|
+
}
|
|
8
137
|
|
|
9
138
|
|
|
10
139
|
def request_headers() -> dict[str, str]:
|
|
@@ -59,6 +188,8 @@ def chat_request(
|
|
|
59
188
|
"model": model,
|
|
60
189
|
"messages": messages,
|
|
61
190
|
"stream": False,
|
|
191
|
+
"truncate": False,
|
|
192
|
+
"shift": False,
|
|
62
193
|
"format": "json",
|
|
63
194
|
"think": think,
|
|
64
195
|
"keep_alive": keep_alive,
|