Compare commits

...
Author SHA1 Message Date
kristinhe b6a1e2432f fix(memhub): stop the model probe from starving itself
The generation probe asked for 256 output tokens. A thinking model spends
that budget before it writes anything — one measured 470 reasoning tokens to
return a two-field JSON — so the reply came back empty with
finish_reason=length.

The empty body was then read as "this gateway cannot do structured output",
which is a different fact, and that code fired the schema fallback with the
same starved budget. A working model was recorded as unavailable, and the
dialog told the person to compare model names.

Give the probe a budget that covers thinking plus the answer, report
truncation as truncation, and stop sending the disable-thinking preference:
it is a generation preference, and a model that refuses it (GLM 1210) failed
a probe the model itself would have passed.
2026-10-24 02:05:35 +08:00
kristinhe 6c55ac696b fix(memhub): keep unknown retries off the event loop
The retry-unknown endpoint resolved and requeued every unknown row through
its own connection and commit, on the loop. Behind a model outage that is a
few thousand rows, so the whole Runtime stopped answering and SIGTERM could
not be handled until the process was killed.

Resolve and requeue a set in one transaction each, with the state machine
still deciding every row, and run the database work on a worker thread.

Measured on 1,000 unknown rows: 0.313s per-row handles -> 0.028s per set.
2026-10-24 01:58:37 +08:00
10 changed files with 380 additions and 113 deletions

No files matched your search

+9
View File
@@ -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
+1
View File
@@ -55,6 +55,7 @@ const CODES = new Set([
"model_list_unsupported",
"invalid_response",
"structured_output_unsupported",
"structured_output_truncated",
"embedding_model_required",
"embedding_unavailable",
]);
+18 -8
View File
@@ -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,
+48 -4
View File
@@ -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.
+72 -11
View File
@@ -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
+12 -1
View File
@@ -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",
];
+1
View File
@@ -357,6 +357,7 @@ export type ProbeCode =
| "model_list_unsupported"
| "invalid_response"
| "structured_output_unsupported"
| "structured_output_truncated"
| "embedding_model_required"
| "embedding_unavailable";