Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b6a1e2432f | ||
|
|
6c55ac696b |
No files matched your search
@@ -4,6 +4,15 @@ Hy Memory and MemHub are versioned and released independently.
|
||||
|
||||
## Unreleased
|
||||
|
||||
- Stopped the model probe from starving a thinking model: its output budget now
|
||||
covers reasoning plus the answer, a length-truncated reply is reported as
|
||||
truncation rather than as an unsupported structured output, and the probe no
|
||||
longer carries the disable-thinking preference, which models that always
|
||||
think reject. The settings dialog now names the stage that actually failed.
|
||||
- Kept a confirmed unknown-retry from taking the event loop: resolution and
|
||||
requeue now share one database handle per pass over the set instead of one
|
||||
per row, and the database work runs on a worker thread, so other endpoints
|
||||
and shutdown keep answering while a backlog drains.
|
||||
- Fixed legacy-to-SDK startup migration to adopt native Coding Experience and
|
||||
Working Fact IDs instead of importing them as Chat layers. Raw migration now
|
||||
rebuilds bounded user/assistant turns from the latest three days, excludes
|
||||
|
||||
@@ -55,6 +55,7 @@ const CODES = new Set([
|
||||
"model_list_unsupported",
|
||||
"invalid_response",
|
||||
"structured_output_unsupported",
|
||||
"structured_output_truncated",
|
||||
"embedding_model_required",
|
||||
"embedding_unavailable",
|
||||
]);
|
||||
|
||||
@@ -3939,19 +3939,29 @@ def create_app(
|
||||
async def retry_unknown_memory_tasks(
|
||||
request: RetryUnknownMemoryTasksRequest,
|
||||
) -> RetryUnknownMemoryTasksReceipt:
|
||||
"""Retry unknown writes only behind the user's explicit confirmation."""
|
||||
"""Retry unknown writes only behind the user's explicit confirmation.
|
||||
|
||||
The database work runs on a worker thread: it is a bounded set of rows,
|
||||
not a handful, and doing it on the loop is what made every other
|
||||
endpoint (and the shutdown signal) wait behind it.
|
||||
"""
|
||||
del request
|
||||
reconciled = await _sdk_memory_runtime(app).reconcile_unknown_outputs()
|
||||
from one_memory.memory_server.durable_queue import DurableWriteQueue
|
||||
|
||||
queue = DurableWriteQueue(app.state.memory_write_queue_path)
|
||||
unknown = queue.unknown_jobs(limit=2048)
|
||||
if app.state.memory_authority is MemoryAuthority.LEGACY:
|
||||
for job in unknown:
|
||||
queue.resolve_unknown_from_linked_output(job.job_id)
|
||||
requeued = sum(queue.retry_unknown_confirmed(job.job_id) for job in unknown)
|
||||
if app.state.memory_authority is MemoryAuthority.LEGACY:
|
||||
requeued += app.state.memory_task_store.retry_unknown_confirmed()
|
||||
legacy_authority = app.state.memory_authority is MemoryAuthority.LEGACY
|
||||
|
||||
def retry_unknown() -> int:
|
||||
job_ids = tuple(job.job_id for job in queue.unknown_jobs(limit=2048))
|
||||
if legacy_authority:
|
||||
queue.resolve_unknown_outputs(job_ids)
|
||||
requeued = queue.retry_unknown_confirmed_many(job_ids)
|
||||
if legacy_authority:
|
||||
requeued += app.state.memory_task_store.retry_unknown_confirmed()
|
||||
return requeued
|
||||
|
||||
requeued = await asyncio.to_thread(retry_unknown)
|
||||
return RetryUnknownMemoryTasksReceipt(reconciled=reconciled, requeued=requeued)
|
||||
|
||||
@app.get(
|
||||
|
||||
@@ -20,7 +20,7 @@ import re
|
||||
import shutil
|
||||
import sqlite3
|
||||
import time as _time
|
||||
from collections.abc import Awaitable, Callable
|
||||
from collections.abc import Awaitable, Callable, Iterable
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
@@ -1767,6 +1767,21 @@ class DurableWriteQueue:
|
||||
Returns the new state, or `None` when the row is missing or not
|
||||
`unknown` (an already-resolved row is left untouched).
|
||||
"""
|
||||
with (
|
||||
main_database(self.database_path).write(row_factory=sqlite3.Row) as connection,
|
||||
connection,
|
||||
):
|
||||
return self._resolve_unknown_in(connection, job_id, found=found, detail=detail)
|
||||
|
||||
def _resolve_unknown_in(
|
||||
self,
|
||||
connection: sqlite3.Connection,
|
||||
job_id: str,
|
||||
*,
|
||||
found: bool,
|
||||
detail: str,
|
||||
) -> str | None:
|
||||
"""The inquiry decision itself, so a caller's batch can share one transaction."""
|
||||
from one_memory.memory_server.contracts import WriteJobState as JobState
|
||||
from one_memory.memory_server.job_state import (
|
||||
IllegalTransition,
|
||||
@@ -1777,31 +1792,27 @@ class DurableWriteQueue:
|
||||
event = JobEvent.RESOLVED if found else JobEvent.GIVE_UP
|
||||
target = next_state(JobState.UNKNOWN, event)
|
||||
now = datetime.now(UTC).isoformat()
|
||||
with (
|
||||
main_database(self.database_path).write(row_factory=sqlite3.Row) as connection,
|
||||
connection,
|
||||
):
|
||||
row = connection.execute(
|
||||
"SELECT state FROM memory_write_jobs WHERE job_id=?", (job_id,)
|
||||
).fetchone()
|
||||
if row is None or str(row["state"]) != UNKNOWN:
|
||||
return None
|
||||
# Re-affirm the transition against the row we actually hold, so a
|
||||
# race that changed it cannot be forced into an illegal state.
|
||||
try:
|
||||
next_state(JobState(str(row["state"])), event)
|
||||
except IllegalTransition:
|
||||
return None
|
||||
connection.execute(
|
||||
"UPDATE memory_write_jobs SET state=?, lease_owner=NULL, "
|
||||
"lease_expires_at=NULL, last_error=?, updated_at=? WHERE job_id=?",
|
||||
(
|
||||
str(target),
|
||||
detail or (None if found else "model_content_outcome_not_found"),
|
||||
now,
|
||||
job_id,
|
||||
),
|
||||
)
|
||||
row = connection.execute(
|
||||
"SELECT state FROM memory_write_jobs WHERE job_id=?", (job_id,)
|
||||
).fetchone()
|
||||
if row is None or str(row["state"]) != UNKNOWN:
|
||||
return None
|
||||
# Re-affirm the transition against the row we actually hold, so a
|
||||
# race that changed it cannot be forced into an illegal state.
|
||||
try:
|
||||
next_state(JobState(str(row["state"])), event)
|
||||
except IllegalTransition:
|
||||
return None
|
||||
connection.execute(
|
||||
"UPDATE memory_write_jobs SET state=?, lease_owner=NULL, "
|
||||
"lease_expires_at=NULL, last_error=?, updated_at=? WHERE job_id=?",
|
||||
(
|
||||
str(target),
|
||||
detail or (None if found else "model_content_outcome_not_found"),
|
||||
now,
|
||||
job_id,
|
||||
),
|
||||
)
|
||||
return str(target)
|
||||
|
||||
def retry_unknown_confirmed(self, job_id: str) -> int:
|
||||
@@ -1814,45 +1825,64 @@ class DurableWriteQueue:
|
||||
short transaction, so the unified queue remains the only execution
|
||||
entry and a half-reset attempt cannot become permanently stranded.
|
||||
"""
|
||||
now = datetime.now(UTC).isoformat()
|
||||
return self.retry_unknown_confirmed_many((job_id,))
|
||||
|
||||
def retry_unknown_confirmed_many(self, job_ids: Iterable[str]) -> int:
|
||||
"""Requeue a confirmed set in **one** transaction.
|
||||
|
||||
Same rule as the single-row form, applied to a set: the state machine
|
||||
is still consulted per row, but the set shares one connection and one
|
||||
commit. A per-row connection is what turned a 2,048-row retry into
|
||||
hours of synchronous database work on the caller's thread.
|
||||
"""
|
||||
ids = tuple(job_ids)
|
||||
if not ids:
|
||||
return 0
|
||||
requeued = 0
|
||||
with (
|
||||
main_database(self.database_path).write(row_factory=sqlite3.Row) as connection,
|
||||
connection,
|
||||
):
|
||||
row = connection.execute(
|
||||
"SELECT state,last_error FROM memory_write_jobs WHERE job_id=?", (job_id,)
|
||||
).fetchone()
|
||||
if (
|
||||
row is None
|
||||
or str(row["state"]) != UNKNOWN
|
||||
or str(row["last_error"]) in NON_RETRYABLE_POST_WRITE_CODES
|
||||
):
|
||||
return 0
|
||||
connection.execute(
|
||||
"""
|
||||
UPDATE memory_tasks
|
||||
SET status='pending', runner_token=NULL, attempts=0,
|
||||
last_error_code='model_content_manual_retry_requested', updated_at=?
|
||||
WHERE status='unknown' AND task_id IN (
|
||||
SELECT inputs.task_id
|
||||
FROM memory_task_inputs AS inputs, json_each(inputs.refs_json) AS ref
|
||||
JOIN memory_write_job_evidence AS linked
|
||||
ON linked.evidence_id=json_extract(ref.value, '$.evidence_id')
|
||||
WHERE linked.job_id=?
|
||||
)
|
||||
""",
|
||||
(now, job_id),
|
||||
for job_id in ids:
|
||||
requeued += self._retry_unknown_confirmed_in(connection, job_id)
|
||||
return requeued
|
||||
|
||||
def _retry_unknown_confirmed_in(self, connection: sqlite3.Connection, job_id: str) -> int:
|
||||
now = datetime.now(UTC).isoformat()
|
||||
row = connection.execute(
|
||||
"SELECT state,last_error FROM memory_write_jobs WHERE job_id=?", (job_id,)
|
||||
).fetchone()
|
||||
if (
|
||||
row is None
|
||||
or str(row["state"]) != UNKNOWN
|
||||
or str(row["last_error"]) in NON_RETRYABLE_POST_WRITE_CODES
|
||||
):
|
||||
return 0
|
||||
connection.execute(
|
||||
"""
|
||||
UPDATE memory_tasks
|
||||
SET status='pending', runner_token=NULL, attempts=0,
|
||||
last_error_code='model_content_manual_retry_requested', updated_at=?
|
||||
WHERE status='unknown' AND task_id IN (
|
||||
SELECT inputs.task_id
|
||||
FROM memory_task_inputs AS inputs, json_each(inputs.refs_json) AS ref
|
||||
JOIN memory_write_job_evidence AS linked
|
||||
ON linked.evidence_id=json_extract(ref.value, '$.evidence_id')
|
||||
WHERE linked.job_id=?
|
||||
)
|
||||
changed = connection.execute(
|
||||
"""
|
||||
UPDATE memory_write_jobs
|
||||
SET state='pending', attempts=0, lease_owner=NULL,
|
||||
lease_expires_at=NULL, hy_invoked=0,
|
||||
last_error='model_content_manual_retry_requested', updated_at=?
|
||||
WHERE job_id=? AND state='unknown'
|
||||
""",
|
||||
(now, job_id),
|
||||
).rowcount
|
||||
""",
|
||||
(now, job_id),
|
||||
)
|
||||
changed = connection.execute(
|
||||
"""
|
||||
UPDATE memory_write_jobs
|
||||
SET state='pending', attempts=0, lease_owner=NULL,
|
||||
lease_expires_at=NULL, hy_invoked=0,
|
||||
last_error='model_content_manual_retry_requested', updated_at=?
|
||||
WHERE job_id=? AND state='unknown'
|
||||
""",
|
||||
(now, job_id),
|
||||
).rowcount
|
||||
return changed
|
||||
|
||||
def readmit_cancelled_permission_confirmed(self, job_id: str, request: WriteRequest) -> int:
|
||||
@@ -2020,35 +2050,57 @@ class DurableWriteQueue:
|
||||
)
|
||||
return len(job_ids)
|
||||
|
||||
def resolve_unknown_from_linked_output(self, job_id: str) -> bool:
|
||||
"""Close an unknown durable row when local governance output proves success."""
|
||||
with main_database(self.database_path).read(row_factory=sqlite3.Row) as connection:
|
||||
row = connection.execute(
|
||||
"""
|
||||
SELECT 1
|
||||
FROM memory_write_job_evidence AS linked
|
||||
JOIN memory_task_inputs AS inputs
|
||||
ON EXISTS (
|
||||
SELECT 1 FROM json_each(inputs.refs_json) AS ref
|
||||
WHERE json_extract(ref.value, '$.evidence_id')=linked.evidence_id
|
||||
)
|
||||
JOIN memory_tasks AS task ON task.task_id=inputs.task_id
|
||||
JOIN memory_task_outputs AS output ON output.task_id=task.task_id
|
||||
WHERE linked.job_id=? AND task.status='completed'
|
||||
LIMIT 1
|
||||
""",
|
||||
(job_id,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return False
|
||||
return (
|
||||
self.resolve_unknown(
|
||||
job_id,
|
||||
found=True,
|
||||
detail="model_content_outcome_reconciled",
|
||||
)
|
||||
== COMPLETED
|
||||
)
|
||||
def resolve_unknown_outputs(self, job_ids: Iterable[str]) -> int:
|
||||
"""Close the unknown rows whose local governance output proves success.
|
||||
|
||||
One transaction for the whole set. The state machine still decides each
|
||||
row's next state, so this is the same decision the single-row form made
|
||||
— the only thing removed is the connection-and-commit per job that made
|
||||
a few thousand rows block the Runtime for hours.
|
||||
"""
|
||||
ids = tuple(job_ids)
|
||||
if not ids:
|
||||
return 0
|
||||
resolved = 0
|
||||
with (
|
||||
main_database(self.database_path).write(row_factory=sqlite3.Row) as connection,
|
||||
connection,
|
||||
):
|
||||
for job_id in ids:
|
||||
if not self._linked_output_proves_success(connection, job_id):
|
||||
continue
|
||||
if (
|
||||
self._resolve_unknown_in(
|
||||
connection,
|
||||
job_id,
|
||||
found=True,
|
||||
detail="model_content_outcome_reconciled",
|
||||
)
|
||||
== COMPLETED
|
||||
):
|
||||
resolved += 1
|
||||
return resolved
|
||||
|
||||
@staticmethod
|
||||
def _linked_output_proves_success(connection: sqlite3.Connection, job_id: str) -> bool:
|
||||
"""Whether a completed governance task already wrote this Evidence."""
|
||||
row = connection.execute(
|
||||
"""
|
||||
SELECT 1
|
||||
FROM memory_write_job_evidence AS linked
|
||||
JOIN memory_task_inputs AS inputs
|
||||
ON EXISTS (
|
||||
SELECT 1 FROM json_each(inputs.refs_json) AS ref
|
||||
WHERE json_extract(ref.value, '$.evidence_id')=linked.evidence_id
|
||||
)
|
||||
JOIN memory_tasks AS task ON task.task_id=inputs.task_id
|
||||
JOIN memory_task_outputs AS output ON output.task_id=task.task_id
|
||||
WHERE linked.job_id=? AND task.status='completed'
|
||||
LIMIT 1
|
||||
""",
|
||||
(job_id,),
|
||||
).fetchone()
|
||||
return row is not None
|
||||
|
||||
def unknown_jobs(
|
||||
self,
|
||||
|
||||
@@ -16,7 +16,6 @@ from one_memory.model_profiles import (
|
||||
ModelProfilePatch,
|
||||
ModelProfileStore,
|
||||
ProviderKind,
|
||||
attach_disable_thinking,
|
||||
)
|
||||
from one_memory.secrets import SecretStore
|
||||
|
||||
@@ -27,6 +26,10 @@ _operation: ContextVar[tuple["ModelProbeOperations", str] | None] = ContextVar(
|
||||
"model_probe_operation", default=None
|
||||
)
|
||||
|
||||
# Output budget for the generation probe. It has to cover the model's thinking
|
||||
# plus the four-field JSON, because the answer only starts after thinking ends.
|
||||
PROBE_MAX_TOKENS = 4096
|
||||
|
||||
|
||||
class ProbeCode(StrEnum):
|
||||
OK = "ok"
|
||||
@@ -42,6 +45,10 @@ class ProbeCode(StrEnum):
|
||||
MODEL_LIST_UNSUPPORTED = "model_list_unsupported"
|
||||
INVALID_RESPONSE = "invalid_response"
|
||||
STRUCTURED_OUTPUT_UNSUPPORTED = "structured_output_unsupported"
|
||||
# The model answered, but spent the whole output budget before it reached
|
||||
# the JSON. That is a budget fact, not a capability fact: treating it as
|
||||
# "cannot do structured output" blamed the model for a 256-token probe.
|
||||
STRUCTURED_OUTPUT_TRUNCATED = "structured_output_truncated"
|
||||
EMBEDDING_MODEL_REQUIRED = "embedding_model_required"
|
||||
EMBEDDING_UNAVAILABLE = "embedding_unavailable"
|
||||
|
||||
@@ -376,6 +383,19 @@ class ModelProber:
|
||||
if require_structured_json:
|
||||
try:
|
||||
body = response.json()
|
||||
except (ValueError, TypeError):
|
||||
return _stage(stage, False, ProbeCode.INVALID_RESPONSE, started)
|
||||
if _finish_reason(body) == "length":
|
||||
# Stopped at the budget. Whether the model can emit this JSON
|
||||
# is still unknown, so it must not be reported as a refusal
|
||||
# and must not trigger the schema fallback below.
|
||||
return _stage(
|
||||
stage,
|
||||
False,
|
||||
ProbeCode.STRUCTURED_OUTPUT_TRUNCATED,
|
||||
started,
|
||||
)
|
||||
try:
|
||||
content = _response_content(body)
|
||||
parsed = json.loads(content) if isinstance(content, str) else content
|
||||
except (ValueError, TypeError):
|
||||
@@ -523,7 +543,11 @@ def _structured_request(
|
||||
)
|
||||
payload: dict[str, object] = {
|
||||
"model": profile.model,
|
||||
"max_tokens": 256,
|
||||
# A reasoning model spends output tokens before it writes any answer:
|
||||
# one measured 470 thinking tokens to return a two-field JSON. The old
|
||||
# 256 was small enough that the probe itself starved the model and then
|
||||
# reported the empty body as an unsupported capability.
|
||||
"max_tokens": PROBE_MAX_TOKENS,
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
@@ -538,8 +562,11 @@ def _structured_request(
|
||||
"type": "json_schema",
|
||||
"json_schema": {"name": "memory_probe", "strict": True, "schema": schema},
|
||||
}
|
||||
if profile.disable_thinking:
|
||||
attach_disable_thinking(profile, payload)
|
||||
# `disable_thinking` is a generation preference: it changes the request the
|
||||
# product sends once configured. Probing with it on made a connectivity
|
||||
# answer depend on an optional parameter, and models that refuse to stop
|
||||
# thinking (GLM reports 1210) failed a probe that the model itself would
|
||||
# have passed. The probe asks only whether the model answers.
|
||||
return (
|
||||
"POST",
|
||||
f"{base_url}/chat/completions",
|
||||
@@ -702,6 +729,23 @@ def _response_content(body: object) -> object:
|
||||
return body
|
||||
|
||||
|
||||
def _finish_reason(body: object) -> str | None:
|
||||
"""Why the completion ended, as the gateway reported it.
|
||||
|
||||
`length` is the only value the probe acts on: an empty body that stopped
|
||||
for length was never asked to completion, so it says nothing about whether
|
||||
the model can produce JSON.
|
||||
"""
|
||||
if not isinstance(body, dict):
|
||||
return None
|
||||
choices = body.get("choices")
|
||||
if isinstance(choices, list) and choices and isinstance(choices[0], dict):
|
||||
reason = choices[0].get("finish_reason")
|
||||
return reason if isinstance(reason, str) else None
|
||||
stop_reason = body.get("stop_reason")
|
||||
return stop_reason if isinstance(stop_reason, str) else None
|
||||
|
||||
|
||||
def _models_include(body: object, expected: str) -> bool:
|
||||
if not isinstance(body, dict):
|
||||
return False
|
||||
|
||||
@@ -2394,6 +2394,83 @@ def test_user_confirmed_unknown_retry_requeues_job_and_linked_tasks(tmp_path) ->
|
||||
assert queue.retry_unknown_confirmed(record.job_id) == 0
|
||||
|
||||
|
||||
def test_confirmed_retry_batch_resolves_and_requeues_with_one_handle(
|
||||
tmp_path, monkeypatch
|
||||
) -> None:
|
||||
"""A confirmed retry set pays one database handle, not one per row.
|
||||
|
||||
Regression: the endpoint resolved and requeued every unknown row through
|
||||
its own connection and commit. A few thousand unknowns therefore held the
|
||||
event loop — and with it every other endpoint and the shutdown signal —
|
||||
for hours, while the same work in one transaction finishes in seconds.
|
||||
"""
|
||||
path = tmp_path / "one-memory.sqlite3"
|
||||
queue = DurableWriteQueue(path)
|
||||
proven = queue.submit(_request(evidence=("e1",), key="idem-proven"))
|
||||
missing = queue.submit(_request(evidence=("e2",), key="idem-missing"))
|
||||
untouched = queue.submit(_request(evidence=("e3",), key="idem-pending"))
|
||||
|
||||
with sqlite3.connect(path) as connection, connection:
|
||||
connection.execute(
|
||||
"UPDATE memory_write_jobs SET state='unknown', attempts=2, hy_invoked=1, "
|
||||
"last_error='hy_memory_timeout' WHERE job_id IN (?,?)",
|
||||
(proven.job_id, missing.job_id),
|
||||
)
|
||||
connection.executescript(
|
||||
"""
|
||||
CREATE TABLE memory_tasks (
|
||||
task_id TEXT PRIMARY KEY,
|
||||
status TEXT NOT NULL,
|
||||
runner_token TEXT,
|
||||
attempts INTEGER NOT NULL,
|
||||
last_error_code TEXT,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE memory_task_inputs (
|
||||
task_id TEXT PRIMARY KEY,
|
||||
refs_json TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE memory_task_outputs (
|
||||
task_id TEXT NOT NULL,
|
||||
memory_id TEXT NOT NULL,
|
||||
body TEXT NOT NULL
|
||||
);
|
||||
"""
|
||||
)
|
||||
connection.execute(
|
||||
"INSERT INTO memory_tasks VALUES (?,?,?,?,?,?)",
|
||||
("task-1", "completed", None, 1, None, "old"),
|
||||
)
|
||||
connection.execute(
|
||||
"INSERT INTO memory_task_inputs VALUES (?,?)",
|
||||
("task-1", '[{"evidence_id":"e1"}]'),
|
||||
)
|
||||
connection.execute(
|
||||
"INSERT INTO memory_task_outputs VALUES (?,?,?)",
|
||||
("task-1", "m-1", "{}"),
|
||||
)
|
||||
|
||||
import one_memory.memory_server.durable_queue as durable_queue_module
|
||||
|
||||
handles: list[int] = []
|
||||
real_main_database = durable_queue_module.main_database
|
||||
|
||||
def counting_main_database(*args, **kwargs):
|
||||
handles.append(1)
|
||||
return real_main_database(*args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(durable_queue_module, "main_database", counting_main_database)
|
||||
job_ids = (proven.job_id, missing.job_id, untouched.job_id)
|
||||
|
||||
assert queue.resolve_unknown_outputs(job_ids) == 1
|
||||
assert queue.retry_unknown_confirmed_many(job_ids) == 1
|
||||
assert handles == [1, 1], "one handle per pass over the set, not one per row"
|
||||
|
||||
assert queue.get(proven.request_id).state == COMPLETED
|
||||
assert queue.get(missing.request_id).state == PENDING
|
||||
assert queue.get(untouched.request_id).state == PENDING
|
||||
|
||||
|
||||
def test_restart_redrains_a_job_claimed_by_a_worker_that_died(tmp_path) -> None:
|
||||
"""End-to-end restart recovery: a claimed-but-not-run job runs once.
|
||||
|
||||
|
||||
@@ -6,7 +6,12 @@ import httpx
|
||||
import pytest
|
||||
from pydantic import SecretStr
|
||||
|
||||
from one_memory.model_probe import ModelProber, ProbeCode, _structured_request
|
||||
from one_memory.model_probe import (
|
||||
PROBE_MAX_TOKENS,
|
||||
ModelProber,
|
||||
ProbeCode,
|
||||
_structured_request,
|
||||
)
|
||||
from one_memory.model_profiles import (
|
||||
ModelProfileInput,
|
||||
ModelProfileStore,
|
||||
@@ -695,7 +700,7 @@ def test_bounded_openai_probe_payload_preserves_profile_and_recipient(
|
||||
|
||||
expected = {
|
||||
"model": "unchanged-model",
|
||||
"max_tokens": 256,
|
||||
"max_tokens": PROBE_MAX_TOKENS,
|
||||
"messages": [
|
||||
{"role": "user", "content": 'Return memory_probe=true as JSON: {"memory_probe": true}'}
|
||||
],
|
||||
@@ -714,11 +719,10 @@ def test_bounded_openai_probe_payload_preserves_profile_and_recipient(
|
||||
},
|
||||
},
|
||||
}
|
||||
if disable_thinking:
|
||||
if base_url == "https://api.deepseek.com/v1":
|
||||
expected["thinking"] = {"type": "disabled"}
|
||||
else:
|
||||
expected["reasoning_effort"] = "none"
|
||||
# A generation preference must not shape the probe: with it on, a model
|
||||
# that refuses to stop thinking failed a probe it would otherwise pass.
|
||||
assert "reasoning_effort" not in expected
|
||||
assert "thinking" not in expected
|
||||
assert (method, url, headers) == (
|
||||
"POST",
|
||||
f"{base_url}/chat/completions",
|
||||
@@ -781,10 +785,8 @@ def test_bounded_probe_transport_keeps_existing_fallback_and_request_count(
|
||||
assert request.url == "https://models.example.com/v1/chat/completions"
|
||||
assert request.headers["Authorization"] == f"Bearer {API_KEY}"
|
||||
assert body["model"] == "unchanged-model"
|
||||
assert body.get("max_tokens") == 256
|
||||
assert ("reasoning_effort" in body) is disable_thinking
|
||||
if disable_thinking:
|
||||
assert body["reasoning_effort"] == "none"
|
||||
assert body["max_tokens"] == PROBE_MAX_TOKENS
|
||||
assert "reasoning_effort" not in body
|
||||
assert "thinking" not in body
|
||||
assert requests[-1].url == "http://127.0.0.1:11434/api/embed"
|
||||
assert "Authorization" not in requests[-1].headers
|
||||
@@ -840,3 +842,62 @@ def test_other_provider_probe_payloads_are_unchanged(tmp_path, provider, force_p
|
||||
"max_tokens": 64,
|
||||
"messages": [{"role": "user", "content": "Return memory_probe=true as JSON"}],
|
||||
}
|
||||
|
||||
|
||||
def test_probe_reports_a_truncated_reply_as_a_budget_fact(tmp_path) -> None:
|
||||
"""A thinking model that ran out of budget is not a model that cannot answer.
|
||||
|
||||
Regression: the probe asked for 256 tokens, a reasoning model spent 251 of
|
||||
them thinking, and the empty body was reported as
|
||||
`structured_output_unsupported`. That code also fired the schema fallback,
|
||||
which re-asked with the same starved budget, so a working model was recorded
|
||||
as unavailable and the dialog blamed the model name.
|
||||
"""
|
||||
store = ModelProfileStore(tmp_path / "one-memory.sqlite3")
|
||||
secrets = InMemorySecretStore()
|
||||
profile = store.create(
|
||||
ModelProfileInput(
|
||||
name="Thinking model",
|
||||
provider_kind=ProviderKind.OPENAI_COMPATIBLE,
|
||||
base_url="https://models.example.com/v1",
|
||||
model="glm-5.3-flash",
|
||||
api_key=SecretStr(API_KEY),
|
||||
embedding_provider_kind=ProviderKind.OLLAMA,
|
||||
embedding_base_url="http://127.0.0.1:11434",
|
||||
embedding_model="unchanged-embedding",
|
||||
embedding_dimensions=2,
|
||||
),
|
||||
secrets,
|
||||
)
|
||||
generation: list[dict] = []
|
||||
|
||||
def handler(request):
|
||||
if request.url.path == "/v1/models":
|
||||
return httpx.Response(200, json={"data": [{"id": "glm-5.3-flash"}]})
|
||||
if request.url.path == "/api/embed":
|
||||
return httpx.Response(200, json={"embeddings": [[0.1, 0.2]]})
|
||||
generation.append(json.loads(request.content))
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"choices": [{"finish_reason": "length", "message": {"content": ""}}],
|
||||
"usage": {
|
||||
"completion_tokens": PROBE_MAX_TOKENS,
|
||||
"completion_tokens_details": {"reasoning_tokens": PROBE_MAX_TOKENS - 5},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
result = asyncio.run(
|
||||
ModelProber(store=store, secrets=secrets, transport=httpx.MockTransport(handler)).probe(
|
||||
profile.profile_id
|
||||
)
|
||||
)
|
||||
|
||||
structured = next(stage for stage in result.stages if stage.stage == "structured_output")
|
||||
assert structured.code is ProbeCode.STRUCTURED_OUTPUT_TRUNCATED
|
||||
# The schema parameter was never the problem, so it is neither dropped nor
|
||||
# re-asked: exactly one generation request, with the schema still on.
|
||||
assert len(generation) == 1
|
||||
assert "response_format" in generation[0]
|
||||
assert generation[0]["max_tokens"] == PROBE_MAX_TOKENS
|
||||
@@ -109,6 +109,9 @@ const FIELD_PROBE_TEXT: Partial<Record<ProbeCode, string>> = {
|
||||
"服务商拒绝了这个 API Key(401)。请确认密钥属于当前填写的服务地址。",
|
||||
model_not_found: "服务商没有这个模型名,请对照服务商的模型列表核对。",
|
||||
unreachable: "连不上接口地址,请检查网络和服务地址是否正确。",
|
||||
structured_output_truncated:
|
||||
"模型能连上,但这次回答在输出预算内没有写完(思考占满了额度)。可以重试一次;如果每次都这样,说明这个模型的思考过长。",
|
||||
structured_output_unsupported: "模型能连上,但没能按要求的格式返回结果。",
|
||||
};
|
||||
|
||||
function probeFieldErrors(
|
||||
@@ -129,8 +132,15 @@ function probeFieldErrors(
|
||||
if (!result.capabilities.model_available) {
|
||||
if (stages.get("model")?.code === "unauthorized") {
|
||||
errors["api-key"] = FIELD_PROBE_TEXT.unauthorized;
|
||||
} else {
|
||||
} else if (stages.get("model")?.code !== "ok") {
|
||||
errors.model = text("model") ?? FIELD_PROBE_TEXT.model_not_found;
|
||||
} else {
|
||||
// The listing answered and the model was found: the model name is not
|
||||
// what failed. Blaming it sent people to compare model lists while the
|
||||
// real answer was "the probe truncated the reply".
|
||||
errors.model =
|
||||
text("structured_output") ??
|
||||
FIELD_PROBE_TEXT.structured_output_unsupported;
|
||||
}
|
||||
}
|
||||
if (!result.capabilities.embedding_available) {
|
||||
@@ -1724,6 +1734,7 @@ export function probeLabel(code: ProbeCode): string {
|
||||
model_list_unsupported: "该网关不提供模型列表,已改由生成调用验证",
|
||||
invalid_response: "服务返回不符合协议",
|
||||
structured_output_unsupported: "结构化输出未通过",
|
||||
structured_output_truncated: "输出被预算截断;模型思考占满了额度",
|
||||
embedding_model_required: "需要填写 Embedding 模型",
|
||||
embedding_unavailable: "Embedding 模型不可用",
|
||||
};
|
||||
|
||||
@@ -32,6 +32,7 @@ const CODES: readonly ProbeCode[] = [
|
||||
"model_list_unsupported",
|
||||
"invalid_response",
|
||||
"structured_output_unsupported",
|
||||
"structured_output_truncated",
|
||||
"embedding_model_required",
|
||||
"embedding_unavailable",
|
||||
];
|
||||
|
||||
@@ -357,6 +357,7 @@ export type ProbeCode =
|
||||
| "model_list_unsupported"
|
||||
| "invalid_response"
|
||||
| "structured_output_unsupported"
|
||||
| "structured_output_truncated"
|
||||
| "embedding_model_required"
|
||||
| "embedding_unavailable";
|
||||
|
||||
|
||||
Reference in new issue
Block a user