memleaf 0.2.44__tar.gz → 0.2.45__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.
- {memleaf-0.2.44 → memleaf-0.2.45}/CHANGELOG.md +9 -0
- {memleaf-0.2.44/src/memleaf.egg-info → memleaf-0.2.45}/PKG-INFO +2 -2
- {memleaf-0.2.44 → memleaf-0.2.45}/README.en.md +1 -1
- {memleaf-0.2.44 → memleaf-0.2.45}/README.md +1 -1
- {memleaf-0.2.44 → memleaf-0.2.45}/pyproject.toml +1 -1
- {memleaf-0.2.44 → memleaf-0.2.45}/src/memleaf/__init__.py +1 -1
- {memleaf-0.2.44 → memleaf-0.2.45}/src/memleaf/cli.py +13 -1
- {memleaf-0.2.44 → memleaf-0.2.45}/src/memleaf/config.py +55 -0
- {memleaf-0.2.44 → memleaf-0.2.45}/src/memleaf/extraction_budget.py +14 -3
- {memleaf-0.2.44 → memleaf-0.2.45}/src/memleaf/extraction_capability.py +9 -9
- {memleaf-0.2.44 → memleaf-0.2.45}/src/memleaf/hermes_provider/plugin.yaml +1 -1
- {memleaf-0.2.44 → memleaf-0.2.45}/src/memleaf/llm/base.py +5 -2
- memleaf-0.2.45/src/memleaf/llm/openai_compatible.py +312 -0
- {memleaf-0.2.44 → memleaf-0.2.45}/src/memleaf/llm/router.py +18 -11
- {memleaf-0.2.44 → memleaf-0.2.45}/src/memleaf/llm/thinking.py +19 -8
- memleaf-0.2.45/src/memleaf/model_capabilities.py +189 -0
- {memleaf-0.2.44 → memleaf-0.2.45}/src/memleaf/model_discovery.py +31 -16
- {memleaf-0.2.44 → memleaf-0.2.45}/src/memleaf/model_execution.py +60 -1
- {memleaf-0.2.44 → memleaf-0.2.45}/src/memleaf/process_common.py +2 -2
- {memleaf-0.2.44 → memleaf-0.2.45}/src/memleaf/single_pass_memory_planner.py +10 -1
- memleaf-0.2.45/src/memleaf/single_pass_plan.py +1063 -0
- {memleaf-0.2.44 → memleaf-0.2.45}/src/memleaf/validation.py +115 -0
- {memleaf-0.2.44 → memleaf-0.2.45/src/memleaf.egg-info}/PKG-INFO +2 -2
- {memleaf-0.2.44 → memleaf-0.2.45}/src/memleaf.egg-info/SOURCES.txt +1 -0
- memleaf-0.2.44/src/memleaf/llm/openai_compatible.py +0 -165
- memleaf-0.2.44/src/memleaf/single_pass_plan.py +0 -536
- {memleaf-0.2.44 → memleaf-0.2.45}/LICENSE +0 -0
- {memleaf-0.2.44 → memleaf-0.2.45}/MANIFEST.in +0 -0
- {memleaf-0.2.44 → memleaf-0.2.45}/docs/capture-budget-design.md +0 -0
- {memleaf-0.2.44 → memleaf-0.2.45}/docs/config-migrations.md +0 -0
- {memleaf-0.2.44 → memleaf-0.2.45}/docs/core-refactor.md +0 -0
- {memleaf-0.2.44 → memleaf-0.2.45}/docs/evidence-retention.md +0 -0
- {memleaf-0.2.44 → memleaf-0.2.45}/docs/extraction-latency.md +0 -0
- {memleaf-0.2.44 → memleaf-0.2.45}/docs/gate-evidence-boundary.md +0 -0
- {memleaf-0.2.44 → memleaf-0.2.45}/docs/general-processing.md +0 -0
- {memleaf-0.2.44 → memleaf-0.2.45}/docs/hermes-mcp-runtime.md +0 -0
- {memleaf-0.2.44 → memleaf-0.2.45}/docs/processing-quality-acceptance.md +0 -0
- {memleaf-0.2.44 → memleaf-0.2.45}/docs/v0.2.26-processing-status.md +0 -0
- {memleaf-0.2.44 → memleaf-0.2.45}/examples/README.md +0 -0
- {memleaf-0.2.44 → memleaf-0.2.45}/examples/basic_usage.py +0 -0
- {memleaf-0.2.44 → memleaf-0.2.45}/examples/mcp_stdio.ndjson +0 -0
- {memleaf-0.2.44 → memleaf-0.2.45}/install.ps1 +0 -0
- {memleaf-0.2.44 → memleaf-0.2.45}/install.sh +0 -0
- {memleaf-0.2.44 → memleaf-0.2.45}/setup.cfg +0 -0
- {memleaf-0.2.44 → memleaf-0.2.45}/src/memleaf/__main__.py +0 -0
- {memleaf-0.2.44 → memleaf-0.2.45}/src/memleaf/adapters/__init__.py +0 -0
- {memleaf-0.2.44 → memleaf-0.2.45}/src/memleaf/adapters/antigravity.py +0 -0
- {memleaf-0.2.44 → memleaf-0.2.45}/src/memleaf/adapters/base.py +0 -0
- {memleaf-0.2.44 → memleaf-0.2.45}/src/memleaf/adapters/codex.py +0 -0
- {memleaf-0.2.44 → memleaf-0.2.45}/src/memleaf/adapters/hermes.py +0 -0
- {memleaf-0.2.44 → memleaf-0.2.45}/src/memleaf/admission.py +0 -0
- {memleaf-0.2.44 → memleaf-0.2.45}/src/memleaf/batch_review.py +0 -0
- {memleaf-0.2.44 → memleaf-0.2.45}/src/memleaf/budget.py +0 -0
- {memleaf-0.2.44 → memleaf-0.2.45}/src/memleaf/capture.py +0 -0
- {memleaf-0.2.44 → memleaf-0.2.45}/src/memleaf/compaction.py +0 -0
- {memleaf-0.2.44 → memleaf-0.2.45}/src/memleaf/create_coordinator.py +0 -0
- {memleaf-0.2.44 → memleaf-0.2.45}/src/memleaf/credentials.py +0 -0
- {memleaf-0.2.44 → memleaf-0.2.45}/src/memleaf/evidence_budget.py +0 -0
- {memleaf-0.2.44 → memleaf-0.2.45}/src/memleaf/evidence_policy.py +0 -0
- {memleaf-0.2.44 → memleaf-0.2.45}/src/memleaf/evidence_structure.py +0 -0
- {memleaf-0.2.44 → memleaf-0.2.45}/src/memleaf/evidence_syntax.py +0 -0
- {memleaf-0.2.44 → memleaf-0.2.45}/src/memleaf/extraction_work_state.py +0 -0
- {memleaf-0.2.44 → memleaf-0.2.45}/src/memleaf/frontmatter.py +0 -0
- {memleaf-0.2.44 → memleaf-0.2.45}/src/memleaf/hermes_provider/README.md +0 -0
- {memleaf-0.2.44 → memleaf-0.2.45}/src/memleaf/hermes_provider/__init__.py +0 -0
- {memleaf-0.2.44 → memleaf-0.2.45}/src/memleaf/hermes_provider/_mcp_client.py +0 -0
- {memleaf-0.2.44 → memleaf-0.2.45}/src/memleaf/hermes_provider/_provider.py +0 -0
- {memleaf-0.2.44 → memleaf-0.2.45}/src/memleaf/hermes_provider/_shared.py +0 -0
- {memleaf-0.2.44 → memleaf-0.2.45}/src/memleaf/hermes_provider/evidence_budget.py +0 -0
- {memleaf-0.2.44 → memleaf-0.2.45}/src/memleaf/hermes_runtime.py +0 -0
- {memleaf-0.2.44 → memleaf-0.2.45}/src/memleaf/host_events.py +0 -0
- {memleaf-0.2.44 → memleaf-0.2.45}/src/memleaf/host_runtime.py +0 -0
- {memleaf-0.2.44 → memleaf-0.2.45}/src/memleaf/inbox.py +0 -0
- {memleaf-0.2.44 → memleaf-0.2.45}/src/memleaf/index.py +0 -0
- {memleaf-0.2.44 → memleaf-0.2.45}/src/memleaf/inspection.py +0 -0
- {memleaf-0.2.44 → memleaf-0.2.45}/src/memleaf/installer.py +0 -0
- {memleaf-0.2.44 → memleaf-0.2.45}/src/memleaf/llm/__init__.py +0 -0
- {memleaf-0.2.44 → memleaf-0.2.45}/src/memleaf/llm/claude_compatible.py +0 -0
- {memleaf-0.2.44 → memleaf-0.2.45}/src/memleaf/llm/gemini.py +0 -0
- {memleaf-0.2.44 → memleaf-0.2.45}/src/memleaf/locking.py +0 -0
- {memleaf-0.2.44 → memleaf-0.2.45}/src/memleaf/mcp_server.py +0 -0
- {memleaf-0.2.44 → memleaf-0.2.45}/src/memleaf/memory_commit.py +0 -0
- {memleaf-0.2.44 → memleaf-0.2.45}/src/memleaf/memory_planner.py +0 -0
- {memleaf-0.2.44 → memleaf-0.2.45}/src/memleaf/memory_writer.py +0 -0
- {memleaf-0.2.44 → memleaf-0.2.45}/src/memleaf/models.py +0 -0
- {memleaf-0.2.44 → memleaf-0.2.45}/src/memleaf/native_index.py +0 -0
- {memleaf-0.2.44 → memleaf-0.2.45}/src/memleaf/native_registration.py +0 -0
- {memleaf-0.2.44 → memleaf-0.2.45}/src/memleaf/parallel_model.py +0 -0
- {memleaf-0.2.44 → memleaf-0.2.45}/src/memleaf/planning_context.py +0 -0
- {memleaf-0.2.44 → memleaf-0.2.45}/src/memleaf/process_jobs.py +0 -0
- {memleaf-0.2.44 → memleaf-0.2.45}/src/memleaf/process_journal.py +0 -0
- {memleaf-0.2.44 → memleaf-0.2.45}/src/memleaf/process_owner.py +0 -0
- {memleaf-0.2.44 → memleaf-0.2.45}/src/memleaf/processing.py +0 -0
- {memleaf-0.2.44 → memleaf-0.2.45}/src/memleaf/prompts.py +0 -0
- {memleaf-0.2.44 → memleaf-0.2.45}/src/memleaf/provenance.py +0 -0
- {memleaf-0.2.44 → memleaf-0.2.45}/src/memleaf/recording_policy.py +0 -0
- {memleaf-0.2.44 → memleaf-0.2.45}/src/memleaf/redaction.py +0 -0
- {memleaf-0.2.44 → memleaf-0.2.45}/src/memleaf/retention.py +0 -0
- {memleaf-0.2.44 → memleaf-0.2.45}/src/memleaf/retrieval.py +0 -0
- {memleaf-0.2.44 → memleaf-0.2.45}/src/memleaf/retrieval_gate.py +0 -0
- {memleaf-0.2.44 → memleaf-0.2.45}/src/memleaf/scope_maintenance.py +0 -0
- {memleaf-0.2.44 → memleaf-0.2.45}/src/memleaf/scope_state.py +0 -0
- {memleaf-0.2.44 → memleaf-0.2.45}/src/memleaf/service.py +0 -0
- {memleaf-0.2.44 → memleaf-0.2.45}/src/memleaf/source_policy.py +0 -0
- {memleaf-0.2.44 → memleaf-0.2.45}/src/memleaf/state_layout.py +0 -0
- {memleaf-0.2.44 → memleaf-0.2.45}/src/memleaf/summary_batch.py +0 -0
- {memleaf-0.2.44 → memleaf-0.2.45}/src/memleaf/target_reconciliation.py +0 -0
- {memleaf-0.2.44 → memleaf-0.2.45}/src/memleaf/turn_audit.py +0 -0
- {memleaf-0.2.44 → memleaf-0.2.45}/src/memleaf/turn_plan.py +0 -0
- {memleaf-0.2.44 → memleaf-0.2.45}/src/memleaf/update_coordinator.py +0 -0
- {memleaf-0.2.44 → memleaf-0.2.45}/src/memleaf/update_review.py +0 -0
- {memleaf-0.2.44 → memleaf-0.2.45}/src/memleaf/vault.py +0 -0
- {memleaf-0.2.44 → memleaf-0.2.45}/src/memleaf.egg-info/dependency_links.txt +0 -0
- {memleaf-0.2.44 → memleaf-0.2.45}/src/memleaf.egg-info/entry_points.txt +0 -0
- {memleaf-0.2.44 → memleaf-0.2.45}/src/memleaf.egg-info/top_level.txt +0 -0
|
@@ -2,6 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to memleaf are documented here.
|
|
4
4
|
|
|
5
|
+
## 0.2.45 — 2026-09-12
|
|
6
|
+
|
|
7
|
+
- Repair the `b3-single-pass-v1` automatic extraction contract so model output is constrained by the same CREATE / UPDATE / NO_CHANGE / DEFERRED fields, evidence shapes, no-memory/defer reasons, target rules and lookup-completeness checks that Core validates. Pure ASCII decision-case differences are normalized locally; unknown decisions and missing semantic fields still fail closed.
|
|
8
|
+
- Replace the generic second full-prompt retry with one B3-specific structural repair. The repair receives only the previous untrusted B3 object, the compact protocol and allowlisted structural diagnostics; Core rejects candidate count/order changes, evidence/target/scope/memory drift, or any edit outside the explicitly repairable protocol redundancy. Automatic extraction remains limited to at most two actual model requests and never fabricates a terminal disposition.
|
|
9
|
+
- Separate credential/profile name (`provider`), wire protocol (`protocol`) and capability identity (`provider_family`). DeepSeek capabilities are resolved only from explicit configuration, exact legacy provider IDs, or exact official hostnames; custom aliases, model-name substrings and lookalike domains no longer grant provider-specific JSON/thinking behavior or override an explicit protocol. Unknown OpenAI-compatible services fail closed for single-pass capability.
|
|
10
|
+
- Make DeepSeek single-pass primary and repair requests send explicit `thinking: {type: disabled}` and `response_format: {type: json_object}` from the resolved capability profile, with independently configurable bounded output ceilings. Finish-reason truncation is rejected before commit, repair output is never truncated to fit a budget, and transport success, parser rejection and commit acceptance remain separate metrics.
|
|
11
|
+
- Refine thinking telemetry to distinguish requested, applied and observed state, preserve missing usage as unknown, and report observed reasoning when provider usage or response content proves it. The HTTP transport base no longer grants `single_pass_safe` by inheritance; B3 protocol compatibility and bounded one-request transport semantics are evaluated separately.
|
|
12
|
+
- Validation for this release used the exact v0.2.44 source artifact at `c13c6dedacfdf6206fd8ebdaa04c4d84fdc0b919`: 88 focused tests passed, including temporary-Vault atomicity/idempotency/revision-conflict boundaries, provider request serialization, B3 repair fidelity, request-count limits and the advisory ten-second latency rule. No real provider call or production-session replay was used, so real-model first-pass success rate, repair success rate, reasoning reduction and latency improvement remain unmeasured.
|
|
13
|
+
|
|
5
14
|
## 0.2.44 — 2026-09-12
|
|
6
15
|
|
|
7
16
|
- Correct the extraction latency policy: ten seconds is an advisory performance target, not a cancellation or commit deadline. Remove the fixed six-second primary cap and eight-second shared window; single-pass HTTP requests now honor `llm.request_timeout`, including the one bounded format repair.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: memleaf
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.45
|
|
4
4
|
Summary: A local-first Markdown memory core for AI agents
|
|
5
5
|
Author: memleaf contributors
|
|
6
6
|
License-Expression: MIT
|
|
@@ -23,7 +23,7 @@ Dynamic: license-file
|
|
|
23
23
|
|
|
24
24
|
[English](README.en.md) · [PyPI](https://pypi.org/project/memleaf/) · [GitHub](https://github.com/miffyblueboo/memleaf)
|
|
25
25
|
|
|
26
|
-
> **版本:0.2.
|
|
26
|
+
> **版本:0.2.45。**
|
|
27
27
|
> 记忆只从用户与 Agent 的可见对话提炼。Agent 已在回复中整理的事实、项目进展和明确待办可作为来源;邮件、附件、网页和终端等工具原文不进入记忆提炼。模型负责保留原话中的不确定性,现有记忆仅用于比较、去重和更新。Markdown 仍是唯一事实源。
|
|
28
28
|
> **当前版本支持 Hermes 和 Codex。** Antigravity(反重力)不检测、不安装、不配置。
|
|
29
29
|
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
[中文](README.md) · [PyPI](https://pypi.org/project/memleaf/) · [GitHub](https://github.com/miffyblueboo/memleaf)
|
|
6
6
|
|
|
7
|
-
> **Version: 0.2.
|
|
7
|
+
> **Version: 0.2.45.**
|
|
8
8
|
> Automatic extraction now uses only the current turn's visible user input and final assistant reply. Raw tool output, attachments, web/file/terminal payloads and legacy tool-evidence bodies are not new source evidence; existing or retrieved memory remains comparison context rather than source authority. Background processing is persisted as a local job and can be checked through the read-only `process_status` MCP tool; failed work remains retryable and fail closed. The release also tightens source/date grounding, target reconciliation, duplicate/no-op handling and semantic review before writes. Markdown remains the sole source of truth with no SQLite runtime dependency. Acceptance covers deterministic regression suites and synthetic inputs; it does not claim real-mail or customer-business acceptance.
|
|
9
9
|
> **The current release supports Hermes and Codex.** Antigravity is not detected, installed, or configured.
|
|
10
10
|
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
[English](README.en.md) · [PyPI](https://pypi.org/project/memleaf/) · [GitHub](https://github.com/miffyblueboo/memleaf)
|
|
6
6
|
|
|
7
|
-
> **版本:0.2.
|
|
7
|
+
> **版本:0.2.45。**
|
|
8
8
|
> 记忆只从用户与 Agent 的可见对话提炼。Agent 已在回复中整理的事实、项目进展和明确待办可作为来源;邮件、附件、网页和终端等工具原文不进入记忆提炼。模型负责保留原话中的不确定性,现有记忆仅用于比较、去重和更新。Markdown 仍是唯一事实源。
|
|
9
9
|
> **当前版本支持 Hermes 和 Codex。** Antigravity(反重力)不检测、不安装、不配置。
|
|
10
10
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"""Local-first Markdown memory core for AI agents."""
|
|
2
2
|
|
|
3
|
-
__version__ = "0.2.
|
|
3
|
+
__version__ = "0.2.45"
|
|
4
4
|
|
|
5
5
|
from .config import DEFAULT_CONFIG, default_config, load_config, save_config
|
|
6
6
|
from .frontmatter import FrontmatterError, dump_frontmatter, dump_yaml, load_yaml, parse_frontmatter
|
|
@@ -22,6 +22,7 @@ from .adapters.base import (
|
|
|
22
22
|
from .adapters.hermes import HermesAdapter
|
|
23
23
|
from .credentials import credential_text
|
|
24
24
|
from .config import load_config
|
|
25
|
+
from .model_capabilities import resolve_provider_capabilities
|
|
25
26
|
from .model_discovery import ModelCandidate, discover_models, manual_candidate, write_model_config
|
|
26
27
|
from .vault import Vault
|
|
27
28
|
|
|
@@ -531,6 +532,11 @@ def _existing_memleaf_route(path: Path) -> ModelCandidate | None:
|
|
|
531
532
|
if key is None:
|
|
532
533
|
return None
|
|
533
534
|
try:
|
|
535
|
+
capabilities = resolve_provider_capabilities(
|
|
536
|
+
provider=llm.get("provider"),
|
|
537
|
+
provider_family=llm.get("provider_family"),
|
|
538
|
+
base_url=llm.get("base_url"),
|
|
539
|
+
)
|
|
534
540
|
return ModelCandidate(
|
|
535
541
|
source="memleaf",
|
|
536
542
|
provider=str(llm.get("provider", "")),
|
|
@@ -540,6 +546,8 @@ def _existing_memleaf_route(path: Path) -> ModelCandidate | None:
|
|
|
540
546
|
api_key=key,
|
|
541
547
|
context_window=int(llm.get("context_window", 200000)),
|
|
542
548
|
source_detail="existing memleaf route",
|
|
549
|
+
provider_family=capabilities.provider_family,
|
|
550
|
+
provider_family_source=capabilities.source,
|
|
543
551
|
)
|
|
544
552
|
except (TypeError, ValueError):
|
|
545
553
|
return None
|
|
@@ -547,13 +555,17 @@ def _existing_memleaf_route(path: Path) -> ModelCandidate | None:
|
|
|
547
555
|
|
|
548
556
|
def _prompt_for_model() -> ModelCandidate:
|
|
549
557
|
print("memleaf: no complete callable chat model was found; configure one for processing.", file=sys.stderr)
|
|
550
|
-
provider = input("Provider
|
|
558
|
+
provider = input("Provider / credential profile name: ").strip()
|
|
559
|
+
provider_family = input(
|
|
560
|
+
"Provider family (openai/deepseek/anthropic/gemini/generic, blank=auto): "
|
|
561
|
+
).strip()
|
|
551
562
|
protocol = input("Protocol (openai/claude/gemini): ").strip()
|
|
552
563
|
base_url = input("Base URL: ").strip()
|
|
553
564
|
model = input("Model: ").strip()
|
|
554
565
|
api_key = getpass.getpass("API key (input hidden): ")
|
|
555
566
|
return manual_candidate(
|
|
556
567
|
provider=provider,
|
|
568
|
+
provider_family=provider_family,
|
|
557
569
|
protocol=protocol,
|
|
558
570
|
base_url=base_url,
|
|
559
571
|
model=model,
|
|
@@ -9,6 +9,9 @@ from typing import Any, Mapping
|
|
|
9
9
|
|
|
10
10
|
from .frontmatter import FrontmatterError, dump_yaml, load_yaml
|
|
11
11
|
from .locking import atomic_write_text
|
|
12
|
+
from .model_capabilities import (
|
|
13
|
+
legacy_protocol_for_provider, normalize_protocol, normalize_provider_family,
|
|
14
|
+
)
|
|
12
15
|
from .native_index import NativeConfigError, validate_native_sources
|
|
13
16
|
from .scope_state import ScopeError, validate_scope_registry
|
|
14
17
|
|
|
@@ -19,6 +22,10 @@ MAX_REQUEST_TIMEOUT = 240
|
|
|
19
22
|
DEFAULT_MODEL_CONCURRENCY = 3
|
|
20
23
|
MIN_MODEL_CONCURRENCY = 1
|
|
21
24
|
MAX_MODEL_CONCURRENCY = 8
|
|
25
|
+
DEFAULT_SINGLE_PASS_PRIMARY_MAX_TOKENS = 8192
|
|
26
|
+
DEFAULT_SINGLE_PASS_REPAIR_MAX_TOKENS = 8192
|
|
27
|
+
MIN_SINGLE_PASS_MAX_TOKENS = 2048
|
|
28
|
+
MAX_SINGLE_PASS_MAX_TOKENS = 32768
|
|
22
29
|
THINKING_PURPOSES = ("gate", "summarize", "compact", "single_pass")
|
|
23
30
|
THINKING_MODES = frozenset({"default", "disabled", "low", "high", "max"})
|
|
24
31
|
DEFAULT_THINKING = {purpose: "low" for purpose in THINKING_PURPOSES}
|
|
@@ -40,6 +47,16 @@ def _normalize_request_timeout(value: Any) -> int | float:
|
|
|
40
47
|
return int(parsed) if parsed.is_integer() else parsed
|
|
41
48
|
|
|
42
49
|
|
|
50
|
+
def _normalize_single_pass_token_limit(value: Any, *, field: str, default: int) -> int:
|
|
51
|
+
if value is None or value == "":
|
|
52
|
+
return default
|
|
53
|
+
if isinstance(value, bool) or not isinstance(value, int):
|
|
54
|
+
raise ValueError(f"invalid memleaf llm.{field}")
|
|
55
|
+
if not MIN_SINGLE_PASS_MAX_TOKENS <= value <= MAX_SINGLE_PASS_MAX_TOKENS:
|
|
56
|
+
raise ValueError(f"invalid memleaf llm.{field}")
|
|
57
|
+
return value
|
|
58
|
+
|
|
59
|
+
|
|
43
60
|
def _normalize_model_concurrency(value: Any) -> int:
|
|
44
61
|
if isinstance(value, bool) or not isinstance(value, int):
|
|
45
62
|
raise ValueError("invalid memleaf process.model_concurrency")
|
|
@@ -89,6 +106,7 @@ DEFAULT_CONFIG: dict[str, Any] = {
|
|
|
89
106
|
"llm": {
|
|
90
107
|
"mode": "auto",
|
|
91
108
|
"provider": "",
|
|
109
|
+
"provider_family": "",
|
|
92
110
|
"protocol": "openai",
|
|
93
111
|
"base_url": "",
|
|
94
112
|
"api_key": "",
|
|
@@ -96,6 +114,8 @@ DEFAULT_CONFIG: dict[str, Any] = {
|
|
|
96
114
|
"model": "",
|
|
97
115
|
"context_window": 200000,
|
|
98
116
|
"request_timeout": DEFAULT_REQUEST_TIMEOUT,
|
|
117
|
+
"single_pass_primary_max_tokens": DEFAULT_SINGLE_PASS_PRIMARY_MAX_TOKENS,
|
|
118
|
+
"single_pass_repair_max_tokens": DEFAULT_SINGLE_PASS_REPAIR_MAX_TOKENS,
|
|
99
119
|
"diagnostic_logging": False,
|
|
100
120
|
"thinking": dict(DEFAULT_THINKING),
|
|
101
121
|
},
|
|
@@ -148,6 +168,19 @@ def _normalize_legacy_config(value: Mapping[str, Any]) -> dict[str, Any]:
|
|
|
148
168
|
# the current default restore normal bounded evidence capture.
|
|
149
169
|
current["tool_evidence_mode"] = "off"
|
|
150
170
|
normalized["capture"] = current
|
|
171
|
+
llm = normalized.get("llm")
|
|
172
|
+
if llm is not None and not isinstance(llm, Mapping):
|
|
173
|
+
raise ValueError("invalid memleaf llm settings")
|
|
174
|
+
if isinstance(llm, Mapping):
|
|
175
|
+
current_llm = dict(llm)
|
|
176
|
+
# Old configs often omitted protocol and relied on the provider name.
|
|
177
|
+
# Preserve only exact built-in identifiers; custom aliases never
|
|
178
|
+
# override an explicitly configured protocol.
|
|
179
|
+
if not isinstance(current_llm.get("protocol"), str) or not current_llm.get("protocol", "").strip():
|
|
180
|
+
legacy_protocol = legacy_protocol_for_provider(current_llm.get("provider"))
|
|
181
|
+
if legacy_protocol is not None:
|
|
182
|
+
current_llm["protocol"] = legacy_protocol
|
|
183
|
+
normalized["llm"] = current_llm
|
|
151
184
|
return normalized
|
|
152
185
|
|
|
153
186
|
|
|
@@ -210,7 +243,17 @@ def load_config(path: Path | str, *, vault: Path | str | None = None) -> dict[st
|
|
|
210
243
|
if not isinstance(llm, Mapping):
|
|
211
244
|
raise ValueError("invalid memleaf llm settings")
|
|
212
245
|
llm = dict(llm)
|
|
246
|
+
llm["provider_family"] = normalize_provider_family(llm.get("provider_family"))
|
|
247
|
+
llm["protocol"] = normalize_protocol(llm.get("protocol"), provider=llm.get("provider"))
|
|
213
248
|
llm["request_timeout"] = _normalize_request_timeout(llm.get("request_timeout", DEFAULT_REQUEST_TIMEOUT))
|
|
249
|
+
llm["single_pass_primary_max_tokens"] = _normalize_single_pass_token_limit(
|
|
250
|
+
llm.get("single_pass_primary_max_tokens"),
|
|
251
|
+
field="single_pass_primary_max_tokens", default=DEFAULT_SINGLE_PASS_PRIMARY_MAX_TOKENS,
|
|
252
|
+
)
|
|
253
|
+
llm["single_pass_repair_max_tokens"] = _normalize_single_pass_token_limit(
|
|
254
|
+
llm.get("single_pass_repair_max_tokens"),
|
|
255
|
+
field="single_pass_repair_max_tokens", default=DEFAULT_SINGLE_PASS_REPAIR_MAX_TOKENS,
|
|
256
|
+
)
|
|
214
257
|
llm["thinking"] = _normalize_thinking_settings(llm.get("thinking"))
|
|
215
258
|
if type(llm.get("diagnostic_logging", False)) is not bool:
|
|
216
259
|
raise ValueError("invalid memleaf llm.diagnostic_logging")
|
|
@@ -252,9 +295,21 @@ def save_config(path: Path | str, config: Mapping[str, Any]) -> None:
|
|
|
252
295
|
if not isinstance(llm, Mapping):
|
|
253
296
|
raise ValueError("invalid memleaf llm settings")
|
|
254
297
|
normalized_llm = dict(llm)
|
|
298
|
+
normalized_llm["provider_family"] = normalize_provider_family(normalized_llm.get("provider_family"))
|
|
299
|
+
normalized_llm["protocol"] = normalize_protocol(
|
|
300
|
+
normalized_llm.get("protocol"), provider=normalized_llm.get("provider")
|
|
301
|
+
)
|
|
255
302
|
normalized_llm["request_timeout"] = _normalize_request_timeout(
|
|
256
303
|
normalized_llm.get("request_timeout", DEFAULT_REQUEST_TIMEOUT)
|
|
257
304
|
)
|
|
305
|
+
normalized_llm["single_pass_primary_max_tokens"] = _normalize_single_pass_token_limit(
|
|
306
|
+
normalized_llm.get("single_pass_primary_max_tokens"),
|
|
307
|
+
field="single_pass_primary_max_tokens", default=DEFAULT_SINGLE_PASS_PRIMARY_MAX_TOKENS,
|
|
308
|
+
)
|
|
309
|
+
normalized_llm["single_pass_repair_max_tokens"] = _normalize_single_pass_token_limit(
|
|
310
|
+
normalized_llm.get("single_pass_repair_max_tokens"),
|
|
311
|
+
field="single_pass_repair_max_tokens", default=DEFAULT_SINGLE_PASS_REPAIR_MAX_TOKENS,
|
|
312
|
+
)
|
|
258
313
|
normalized_llm["thinking"] = _normalize_thinking_settings(normalized_llm.get("thinking"))
|
|
259
314
|
diagnostic_logging = normalized_llm.get("diagnostic_logging", False)
|
|
260
315
|
if type(diagnostic_logging) is not bool:
|
|
@@ -26,8 +26,6 @@ class SinglePassBudgetBackend:
|
|
|
26
26
|
its repair overrides the transport's configured ``llm.request_timeout``.
|
|
27
27
|
"""
|
|
28
28
|
|
|
29
|
-
single_pass_safe = True
|
|
30
|
-
|
|
31
29
|
def __init__(
|
|
32
30
|
self,
|
|
33
31
|
backend: Any,
|
|
@@ -50,6 +48,18 @@ class SinglePassBudgetBackend:
|
|
|
50
48
|
def model(self) -> str:
|
|
51
49
|
return str(getattr(self._backend, "model", "unknown"))
|
|
52
50
|
|
|
51
|
+
@property
|
|
52
|
+
def single_pass_safe(self) -> bool:
|
|
53
|
+
"""Preserve the underlying adapter/route request-boundary guarantee."""
|
|
54
|
+
|
|
55
|
+
return getattr(self._backend, "single_pass_safe", False) is True
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def single_pass_protocol(self) -> bool:
|
|
59
|
+
"""Preserve protocol identity while this wrapper enforces request count."""
|
|
60
|
+
|
|
61
|
+
return getattr(self._backend, "single_pass_protocol", False) is True
|
|
62
|
+
|
|
53
63
|
@property
|
|
54
64
|
def parallel_safe(self) -> bool:
|
|
55
65
|
return getattr(self._backend, "parallel_safe", False) is True
|
|
@@ -144,6 +154,7 @@ class ExtractionTiming:
|
|
|
144
154
|
"failed_turn_count": int(failed),
|
|
145
155
|
"over_target_turn_count": int(seconds > TARGET_TOTAL_SECONDS),
|
|
146
156
|
"successful_within_target_count": int(not failed and seconds <= TARGET_TOTAL_SECONDS),
|
|
157
|
+
"commit_accepted_count": int(not failed and commit_started is not None),
|
|
147
158
|
"total_duration_ms": int(seconds * 1000),
|
|
148
159
|
"max_turn_duration_ms": int(seconds * 1000),
|
|
149
160
|
"planning_duration_ms": int(max(0.0, planning_end - self._started) * 1000),
|
|
@@ -156,7 +167,7 @@ def aggregate_extraction_metrics(values: Iterable[Mapping[str, Any]]) -> dict[st
|
|
|
156
167
|
|
|
157
168
|
summed = (
|
|
158
169
|
"turn_count", "failed_turn_count", "over_target_turn_count",
|
|
159
|
-
"successful_within_target_count", "total_duration_ms",
|
|
170
|
+
"successful_within_target_count", "commit_accepted_count", "total_duration_ms",
|
|
160
171
|
"planning_duration_ms", "commit_duration_ms",
|
|
161
172
|
)
|
|
162
173
|
result = {key: 0 for key in summed}
|
|
@@ -32,20 +32,20 @@ def _direct_protocol_capable(backend: Any) -> bool:
|
|
|
32
32
|
|
|
33
33
|
if backend is None:
|
|
34
34
|
return False
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
return declared
|
|
35
|
+
# Raw Python callbacks are adapted by ModelExecutor into CallableBackend.
|
|
36
|
+
# Preserve their established prompt-level B3 compatibility without
|
|
37
|
+
# claiming any transport boundary inside caller-owned code. An explicit
|
|
38
|
+
# callback override can still opt out.
|
|
40
39
|
if isinstance(backend, CallableBackend):
|
|
41
40
|
override = _callable_protocol_override(backend)
|
|
42
41
|
if override is not None:
|
|
43
42
|
return override
|
|
44
|
-
# Raw Python callbacks are adapted by ModelExecutor into
|
|
45
|
-
# CallableBackend. They can consume memleaf's B3 prompt, but are not
|
|
46
|
-
# transport-timeout safe because caller-owned code may ignore
|
|
47
|
-
# timeout/cancellation entirely.
|
|
48
43
|
return True
|
|
44
|
+
# Protocol support is independent of request-boundary safety. A backend
|
|
45
|
+
# must not gain B3 merely because complete() happens to be bounded.
|
|
46
|
+
declared = getattr(backend, "single_pass_protocol", None)
|
|
47
|
+
if isinstance(declared, bool):
|
|
48
|
+
return declared
|
|
49
49
|
return False
|
|
50
50
|
|
|
51
51
|
|
|
@@ -146,6 +146,7 @@ class ModelBackend(Protocol):
|
|
|
146
146
|
parallel_safe: bool
|
|
147
147
|
structured_batch_safe: bool
|
|
148
148
|
single_pass_safe: bool
|
|
149
|
+
single_pass_protocol: bool
|
|
149
150
|
|
|
150
151
|
def complete(
|
|
151
152
|
self,
|
|
@@ -167,7 +168,6 @@ class CallableBackend:
|
|
|
167
168
|
parallel_safe = False
|
|
168
169
|
structured_batch_safe = False
|
|
169
170
|
single_pass_safe = False
|
|
170
|
-
|
|
171
171
|
def __init__(self, callback: Callable[..., str], *, model: str = "host"):
|
|
172
172
|
if not callable(callback):
|
|
173
173
|
raise TypeError("model callback must be callable")
|
|
@@ -231,7 +231,10 @@ class HTTPModelBackend:
|
|
|
231
231
|
provider = "api"
|
|
232
232
|
parallel_safe = False
|
|
233
233
|
structured_batch_safe = True
|
|
234
|
-
|
|
234
|
+
# Protocol compatibility is provider-specific. Generic HTTP transports
|
|
235
|
+
# must not authorize B3 merely because one complete() maps to one request.
|
|
236
|
+
single_pass_safe = False
|
|
237
|
+
single_pass_protocol = False
|
|
235
238
|
|
|
236
239
|
def __init__(
|
|
237
240
|
self,
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
"""OpenAI-compatible chat-completions adapter."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import math
|
|
7
|
+
from typing import Any, Callable, Mapping, Optional
|
|
8
|
+
|
|
9
|
+
from .base import DEFAULT_REQUEST_TIMEOUT, HTTPModelBackend, ModelError
|
|
10
|
+
from .thinking import openai_chat_controls, requested_thinking_mode
|
|
11
|
+
from ..model_capabilities import ProviderCapabilities, resolve_provider_capabilities
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
DEFAULT_SINGLE_PASS_PRIMARY_MAX_TOKENS = 8192
|
|
15
|
+
DEFAULT_SINGLE_PASS_REPAIR_MAX_TOKENS = 8192
|
|
16
|
+
MIN_SINGLE_PASS_MAX_TOKENS = 2048
|
|
17
|
+
MAX_SINGLE_PASS_MAX_TOKENS = 32768
|
|
18
|
+
_REPAIR_TOKEN_MARGIN = 768
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def normalize_single_pass_token_limit(value: Any, *, default: int) -> int:
|
|
22
|
+
if isinstance(value, bool):
|
|
23
|
+
raise ValueError("single-pass token limit must be an integer")
|
|
24
|
+
if value is None or value == "":
|
|
25
|
+
return default
|
|
26
|
+
if not isinstance(value, int) or not MIN_SINGLE_PASS_MAX_TOKENS <= value <= MAX_SINGLE_PASS_MAX_TOKENS:
|
|
27
|
+
raise ValueError(
|
|
28
|
+
f"single-pass token limit must be between {MIN_SINGLE_PASS_MAX_TOKENS} and {MAX_SINGLE_PASS_MAX_TOKENS}"
|
|
29
|
+
)
|
|
30
|
+
return value
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _repair_output_budget(prompt: str, ceiling: int) -> int:
|
|
34
|
+
"""Budget a complete repaired object, never a truncated subset.
|
|
35
|
+
|
|
36
|
+
The project already uses UTF-8 bytes / 4 as a stable local token estimate
|
|
37
|
+
for maintenance. It is not presented as an exact provider tokenizer.
|
|
38
|
+
Here we estimate only the visible previous object (the required output),
|
|
39
|
+
not the repair instructions. If that complete object cannot fit under the
|
|
40
|
+
configured ceiling, the caller fails locally instead of dropping data.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
prefix = "B3_REPAIR_INPUT\n"
|
|
44
|
+
if not prompt.startswith(prefix):
|
|
45
|
+
return ceiling
|
|
46
|
+
try:
|
|
47
|
+
payload_line = prompt[len(prefix):].split("\n", 1)[0]
|
|
48
|
+
payload = json.loads(payload_line)
|
|
49
|
+
previous = payload.get("previous_object") if isinstance(payload, Mapping) else None
|
|
50
|
+
if not isinstance(previous, Mapping):
|
|
51
|
+
return ceiling
|
|
52
|
+
visible = json.dumps(previous, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
|
53
|
+
except (TypeError, ValueError):
|
|
54
|
+
return ceiling
|
|
55
|
+
estimated = max(1, math.ceil(len(visible) / 4)) + _REPAIR_TOKEN_MARGIN
|
|
56
|
+
if estimated > ceiling:
|
|
57
|
+
raise ModelError(
|
|
58
|
+
"single-pass repair output exceeds configured token budget",
|
|
59
|
+
code="model_failed",
|
|
60
|
+
stage="single_pass",
|
|
61
|
+
)
|
|
62
|
+
return max(MIN_SINGLE_PASS_MAX_TOKENS, estimated)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class OpenAICompatibleBackend(HTTPModelBackend):
|
|
66
|
+
provider = "openai"
|
|
67
|
+
|
|
68
|
+
def __init__(
|
|
69
|
+
self,
|
|
70
|
+
*,
|
|
71
|
+
base_url: str,
|
|
72
|
+
api_key: str,
|
|
73
|
+
model: str,
|
|
74
|
+
timeout: float = DEFAULT_REQUEST_TIMEOUT,
|
|
75
|
+
opener: Optional[Callable[..., Any]] = None,
|
|
76
|
+
json_mode: bool | None = None,
|
|
77
|
+
provider_name: str = "",
|
|
78
|
+
capabilities: ProviderCapabilities | None = None,
|
|
79
|
+
thinking: Mapping[str, Any] | None = None,
|
|
80
|
+
single_pass_primary_max_tokens: int = DEFAULT_SINGLE_PASS_PRIMARY_MAX_TOKENS,
|
|
81
|
+
single_pass_repair_max_tokens: int = DEFAULT_SINGLE_PASS_REPAIR_MAX_TOKENS,
|
|
82
|
+
):
|
|
83
|
+
super().__init__(base_url=base_url, api_key=api_key, model=model, timeout=timeout, opener=opener)
|
|
84
|
+
# ``provider_name``/``json_mode`` remain accepted for direct legacy
|
|
85
|
+
# construction. Product routing supplies the already-resolved profile
|
|
86
|
+
# so credential aliases never become a capability signal.
|
|
87
|
+
if capabilities is None:
|
|
88
|
+
capabilities = resolve_provider_capabilities(
|
|
89
|
+
provider=provider_name,
|
|
90
|
+
base_url=base_url,
|
|
91
|
+
)
|
|
92
|
+
self.capabilities = capabilities
|
|
93
|
+
self.provider_family = capabilities.provider_family
|
|
94
|
+
self.provider_family_source = capabilities.source
|
|
95
|
+
self.provider_name = capabilities.provider_family # v0.2.x compatibility attribute
|
|
96
|
+
self.json_mode = capabilities.json_object if json_mode is None else bool(json_mode)
|
|
97
|
+
self.single_pass_protocol = bool(capabilities.b3_protocol and capabilities.json_object)
|
|
98
|
+
# This adapter has one fixed POST per complete() and no hidden retry.
|
|
99
|
+
self.single_pass_safe = self.single_pass_protocol
|
|
100
|
+
self.thinking = dict(thinking) if isinstance(thinking, Mapping) else {}
|
|
101
|
+
self.single_pass_primary_max_tokens = normalize_single_pass_token_limit(
|
|
102
|
+
single_pass_primary_max_tokens,
|
|
103
|
+
default=DEFAULT_SINGLE_PASS_PRIMARY_MAX_TOKENS,
|
|
104
|
+
)
|
|
105
|
+
self.single_pass_repair_max_tokens = normalize_single_pass_token_limit(
|
|
106
|
+
single_pass_repair_max_tokens,
|
|
107
|
+
default=DEFAULT_SINGLE_PASS_REPAIR_MAX_TOKENS,
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
@staticmethod
|
|
111
|
+
def _response_text_chars(value: Any) -> int:
|
|
112
|
+
if isinstance(value, str):
|
|
113
|
+
return min(len(value), 1_000_000)
|
|
114
|
+
if isinstance(value, list):
|
|
115
|
+
total = 0
|
|
116
|
+
for item in value:
|
|
117
|
+
if isinstance(item, str):
|
|
118
|
+
total += len(item)
|
|
119
|
+
elif isinstance(item, Mapping) and isinstance(item.get("text"), str):
|
|
120
|
+
total += len(item["text"])
|
|
121
|
+
return min(total, 1_000_000)
|
|
122
|
+
return 0
|
|
123
|
+
|
|
124
|
+
@classmethod
|
|
125
|
+
def _response_diagnostics(
|
|
126
|
+
cls,
|
|
127
|
+
value: Mapping[str, Any],
|
|
128
|
+
choice: Mapping[str, Any],
|
|
129
|
+
message: Mapping[str, Any],
|
|
130
|
+
) -> dict[str, Any]:
|
|
131
|
+
finish_reason = choice.get("finish_reason")
|
|
132
|
+
if not isinstance(finish_reason, str):
|
|
133
|
+
finish_reason = "unknown"
|
|
134
|
+
else:
|
|
135
|
+
finish_reason = finish_reason.casefold()
|
|
136
|
+
if finish_reason not in {
|
|
137
|
+
"stop", "length", "tool_calls", "function_call",
|
|
138
|
+
"content_filter", "insufficient_system_resource",
|
|
139
|
+
}:
|
|
140
|
+
finish_reason = "unknown"
|
|
141
|
+
usage = value.get("usage")
|
|
142
|
+
completion_tokens = usage.get("completion_tokens") if isinstance(usage, Mapping) else None
|
|
143
|
+
if not (
|
|
144
|
+
isinstance(completion_tokens, int)
|
|
145
|
+
and not isinstance(completion_tokens, bool)
|
|
146
|
+
and 0 <= completion_tokens <= 1_000_000
|
|
147
|
+
):
|
|
148
|
+
completion_tokens = None
|
|
149
|
+
content = message.get("content")
|
|
150
|
+
reasoning = message.get("reasoning_content")
|
|
151
|
+
if reasoning is None:
|
|
152
|
+
reasoning = message.get("reasoning")
|
|
153
|
+
content_chars = cls._response_text_chars(content)
|
|
154
|
+
reasoning_chars = cls._response_text_chars(reasoning)
|
|
155
|
+
return {
|
|
156
|
+
"finish_reason": finish_reason,
|
|
157
|
+
"completion_tokens": completion_tokens,
|
|
158
|
+
"content_present": content_chars > 0,
|
|
159
|
+
"content_chars": content_chars,
|
|
160
|
+
"reasoning_present": reasoning_chars > 0,
|
|
161
|
+
"reasoning_chars": reasoning_chars,
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
@classmethod
|
|
165
|
+
def _usage_metrics(
|
|
166
|
+
cls,
|
|
167
|
+
value: Mapping[str, Any],
|
|
168
|
+
*,
|
|
169
|
+
thinking_metrics: Mapping[str, Any],
|
|
170
|
+
message: Mapping[str, Any] | None,
|
|
171
|
+
max_output_tokens: int | None,
|
|
172
|
+
) -> dict[str, Any]:
|
|
173
|
+
result: dict[str, Any] = dict(thinking_metrics)
|
|
174
|
+
if isinstance(max_output_tokens, int):
|
|
175
|
+
result["max_output_tokens"] = max_output_tokens
|
|
176
|
+
usage = value.get("usage")
|
|
177
|
+
reasoning_observed = False
|
|
178
|
+
reasoning_zero_observed = False
|
|
179
|
+
if isinstance(usage, Mapping):
|
|
180
|
+
for key in (
|
|
181
|
+
"prompt_tokens", "completion_tokens", "total_tokens",
|
|
182
|
+
"prompt_cache_hit_tokens", "prompt_cache_miss_tokens",
|
|
183
|
+
):
|
|
184
|
+
item = usage.get(key)
|
|
185
|
+
if isinstance(item, int) and not isinstance(item, bool) and 0 <= item <= 10_000_000:
|
|
186
|
+
result[key] = item
|
|
187
|
+
prompt_details = usage.get("prompt_tokens_details")
|
|
188
|
+
cached = prompt_details.get("cached_tokens") if isinstance(prompt_details, Mapping) else None
|
|
189
|
+
if "prompt_cache_hit_tokens" not in result and isinstance(cached, int) and not isinstance(cached, bool) and 0 <= cached <= 10_000_000:
|
|
190
|
+
result["prompt_cache_hit_tokens"] = cached
|
|
191
|
+
details = usage.get("completion_tokens_details")
|
|
192
|
+
reasoning = details.get("reasoning_tokens") if isinstance(details, Mapping) else None
|
|
193
|
+
if reasoning is None:
|
|
194
|
+
reasoning = usage.get("reasoning_tokens")
|
|
195
|
+
if isinstance(reasoning, int) and not isinstance(reasoning, bool) and 0 <= reasoning <= 10_000_000:
|
|
196
|
+
result["reasoning_tokens"] = reasoning
|
|
197
|
+
reasoning_observed = reasoning > 0
|
|
198
|
+
reasoning_zero_observed = reasoning == 0
|
|
199
|
+
|
|
200
|
+
reasoning_content = None
|
|
201
|
+
if isinstance(message, Mapping):
|
|
202
|
+
reasoning_content = message.get("reasoning_content")
|
|
203
|
+
if reasoning_content is None:
|
|
204
|
+
reasoning_content = message.get("reasoning")
|
|
205
|
+
reasoning_chars = cls._response_text_chars(reasoning_content)
|
|
206
|
+
if reasoning_chars > 0:
|
|
207
|
+
reasoning_observed = True
|
|
208
|
+
result["thinking_observation_source"] = "reasoning_content"
|
|
209
|
+
elif reasoning_observed:
|
|
210
|
+
result["thinking_observation_source"] = "reasoning_tokens"
|
|
211
|
+
elif reasoning_zero_observed:
|
|
212
|
+
result["thinking_observation_source"] = "reasoning_tokens"
|
|
213
|
+
else:
|
|
214
|
+
result["thinking_observation_source"] = "unavailable"
|
|
215
|
+
|
|
216
|
+
# Request application and observed effect are independent. In
|
|
217
|
+
# particular, sending thinking=disabled is never reported as an
|
|
218
|
+
# effective disable when the response still contains reasoning.
|
|
219
|
+
if reasoning_observed:
|
|
220
|
+
result["thinking_effective"] = "reasoning_observed"
|
|
221
|
+
elif reasoning_zero_observed:
|
|
222
|
+
result["thinking_effective"] = "no_reasoning_observed"
|
|
223
|
+
elif result.get("thinking_applied") is True:
|
|
224
|
+
result["thinking_effective"] = "unknown"
|
|
225
|
+
return result
|
|
226
|
+
|
|
227
|
+
def _single_pass_budget(self, prompt: str) -> int | None:
|
|
228
|
+
if prompt.startswith("B3_REPAIR_INPUT\n"):
|
|
229
|
+
return _repair_output_budget(prompt, self.single_pass_repair_max_tokens)
|
|
230
|
+
return self.single_pass_primary_max_tokens
|
|
231
|
+
|
|
232
|
+
def complete(self, prompt: str, *, system: str = "", purpose: str = "", temperature: float = 0.0) -> str:
|
|
233
|
+
self._set_call_metrics({})
|
|
234
|
+
messages = []
|
|
235
|
+
if system:
|
|
236
|
+
messages.append({"role": "system", "content": system})
|
|
237
|
+
messages.append({"role": "user", "content": prompt})
|
|
238
|
+
requested = requested_thinking_mode(self.thinking, purpose)
|
|
239
|
+
controls, thinking_metrics, omit_temperature = openai_chat_controls(
|
|
240
|
+
self.provider_family, self.model, requested
|
|
241
|
+
)
|
|
242
|
+
payload: dict[str, Any] = {"model": self.model, "messages": messages}
|
|
243
|
+
if not omit_temperature:
|
|
244
|
+
payload["temperature"] = temperature
|
|
245
|
+
payload.update(controls)
|
|
246
|
+
if self.json_mode and purpose in {"gate", "summarize", "compact", "single_pass"}:
|
|
247
|
+
payload["response_format"] = {"type": "json_object"}
|
|
248
|
+
|
|
249
|
+
max_output_tokens: int | None = None
|
|
250
|
+
if purpose == "single_pass" and self.single_pass_protocol:
|
|
251
|
+
max_output_tokens = self._single_pass_budget(prompt)
|
|
252
|
+
field = self.capabilities.output_token_field
|
|
253
|
+
if isinstance(field, str) and field and isinstance(max_output_tokens, int):
|
|
254
|
+
payload[field] = max_output_tokens
|
|
255
|
+
|
|
256
|
+
value = self._post_json(
|
|
257
|
+
self.base_url + "/chat/completions",
|
|
258
|
+
payload,
|
|
259
|
+
{"Authorization": f"Bearer {self.api_key}"},
|
|
260
|
+
stage=purpose,
|
|
261
|
+
)
|
|
262
|
+
choices = value.get("choices")
|
|
263
|
+
if not isinstance(choices, list) or not choices or not isinstance(choices[0], Mapping):
|
|
264
|
+
self._set_call_metrics(self._usage_metrics(
|
|
265
|
+
value, thinking_metrics=thinking_metrics, message=None,
|
|
266
|
+
max_output_tokens=max_output_tokens,
|
|
267
|
+
))
|
|
268
|
+
raise ModelError(
|
|
269
|
+
"model response has no choices",
|
|
270
|
+
code="model_invalid_response",
|
|
271
|
+
stage=purpose,
|
|
272
|
+
validation_reason="response_shape",
|
|
273
|
+
)
|
|
274
|
+
message = choices[0].get("message")
|
|
275
|
+
if not isinstance(message, Mapping):
|
|
276
|
+
self._set_call_metrics(self._usage_metrics(
|
|
277
|
+
value, thinking_metrics=thinking_metrics, message=None,
|
|
278
|
+
max_output_tokens=max_output_tokens,
|
|
279
|
+
))
|
|
280
|
+
raise ModelError(
|
|
281
|
+
"model response has no message",
|
|
282
|
+
code="model_invalid_response",
|
|
283
|
+
stage=purpose,
|
|
284
|
+
validation_reason="response_shape",
|
|
285
|
+
)
|
|
286
|
+
diagnostics = self._response_diagnostics(value, choices[0], message)
|
|
287
|
+
self._set_call_metrics(self._usage_metrics(
|
|
288
|
+
value, thinking_metrics=thinking_metrics, message=message,
|
|
289
|
+
max_output_tokens=max_output_tokens,
|
|
290
|
+
))
|
|
291
|
+
if diagnostics.get("finish_reason") == "length":
|
|
292
|
+
raise ModelError(
|
|
293
|
+
"model response was truncated",
|
|
294
|
+
code="model_invalid_response",
|
|
295
|
+
stage=purpose,
|
|
296
|
+
validation_reason="response_shape",
|
|
297
|
+
).with_response_diagnostics(diagnostics)
|
|
298
|
+
try:
|
|
299
|
+
return self._text(message.get("content"), stage=purpose)
|
|
300
|
+
except ModelError as error:
|
|
301
|
+
error.with_response_diagnostics(diagnostics)
|
|
302
|
+
raise
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
__all__ = [
|
|
306
|
+
"DEFAULT_SINGLE_PASS_PRIMARY_MAX_TOKENS",
|
|
307
|
+
"DEFAULT_SINGLE_PASS_REPAIR_MAX_TOKENS",
|
|
308
|
+
"MIN_SINGLE_PASS_MAX_TOKENS",
|
|
309
|
+
"MAX_SINGLE_PASS_MAX_TOKENS",
|
|
310
|
+
"OpenAICompatibleBackend",
|
|
311
|
+
"normalize_single_pass_token_limit",
|
|
312
|
+
]
|