Spaces:
Sleeping
Sleeping
Publish reviewed AI Engineering Lab snapshot
Browse files- LOCAL_QWEN.md +3 -3
- PUBLIC_MANIFEST.json +19 -19
- README.md +3 -3
- lab/local_planner.py +98 -5
- lab/ui.py +15 -7
- projects/inference_planner/README.md +1 -1
- projects/inference_planner/local_model_notes.md +11 -9
- projects/inference_planner/planner/local_model.py +52 -24
- projects/inference_planner/tests/test_local_model.py +111 -11
- tests/test_local_planner.py +189 -2
LOCAL_QWEN.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
# Optional key-free planning model
|
| 2 |
|
| 3 |
-
This integration is off by default in source. The reviewed deployment enabled it on free ZeroGPU. The first hosted run
|
| 4 |
|
| 5 |
## Activation
|
| 6 |
|
|
@@ -37,9 +37,9 @@ The model can select only the existing read-only planning tools. Typed workload
|
|
| 37 |
|
| 38 |
The original agent validates final catalog IDs and tradeoff codes against recorded tool results. Python renders the accepted selection. Raw model prose is not an approved price comparison. The UI shows the answer as literal text and exposes the complete audit record as JSON. Before a new run, it clears the old answer and trace. A failed generation returns an explicit failure with no cached or fabricated substitute.
|
| 39 |
|
| 40 |
-
After a completed tool response, the local adapter adds a checkpoint containing
|
| 41 |
|
| 42 |
-
|
| 43 |
|
| 44 |
## Release review
|
| 45 |
|
|
|
|
| 1 |
# Optional key-free planning model
|
| 2 |
|
| 3 |
+
This integration is off by default in source. The reviewed deployment enabled it on free ZeroGPU. The first hosted run repeated a tool call and was stopped. The second reached final validation but returned an extra JSON wrapper. The format correction below still needs a successful hosted smoke before chat is described as ready.
|
| 4 |
|
| 5 |
## Activation
|
| 6 |
|
|
|
|
| 37 |
|
| 38 |
The original agent validates final catalog IDs and tradeoff codes against recorded tool results. Python renders the accepted selection. Raw model prose is not an approved price comparison. The UI shows the answer as literal text and exposes the complete audit record as JSON. Before a new run, it clears the old answer and trace. A failed generation returns an explicit failure with no cached or fabricated substitute.
|
| 39 |
|
| 40 |
+
After a completed tool response, the local adapter adds a checkpoint containing effective calls, returned IDs, allowed codes, and four required response fields. It tells the model to finish if the requested comparisons are covered. A second turn may request a distinct missing comparison or break-even calculation. The third turn omits tools and rejects further tool output. One observed `selection_schema` wrapper is removed only when its contents already include all four fields and pass strict validation. The usage record flags that normalization. No choices or defaults are inserted. Original repeat rejection and final ID validation remain unchanged.
|
| 41 |
|
| 42 |
+
The application catches model errors inside the GPU callback before they cross the Spaces decorator. It returns a closed failure envelope with an allowlisted reason code, such as `invalid_final_selection` or `turn_limit`. Application logs omit exception bodies, prompts, raw model output, and tracebacks. Unknown failures receive `unclassified_runtime_error`. Hosting infrastructure can still record its own operational metadata.
|
| 43 |
|
| 44 |
## Release review
|
| 45 |
|
PUBLIC_MANIFEST.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
{
|
| 2 |
"schema_version": "public-ai-lab-release/1",
|
| 3 |
-
"created_at": "2026-08-31T01:
|
| 4 |
"owner": "Yogesh18018",
|
| 5 |
"space_sdk": "gradio",
|
| 6 |
"space_hardware": "zero-a10g",
|
|
@@ -9,8 +9,8 @@
|
|
| 9 |
"files": [
|
| 10 |
{
|
| 11 |
"path": "LOCAL_QWEN.md",
|
| 12 |
-
"bytes":
|
| 13 |
-
"sha256": "
|
| 14 |
},
|
| 15 |
{
|
| 16 |
"path": "PUBLICATION.md",
|
|
@@ -19,8 +19,8 @@
|
|
| 19 |
},
|
| 20 |
{
|
| 21 |
"path": "README.md",
|
| 22 |
-
"bytes":
|
| 23 |
-
"sha256": "
|
| 24 |
},
|
| 25 |
{
|
| 26 |
"path": "app.py",
|
|
@@ -39,8 +39,8 @@
|
|
| 39 |
},
|
| 40 |
{
|
| 41 |
"path": "lab/local_planner.py",
|
| 42 |
-
"bytes":
|
| 43 |
-
"sha256": "
|
| 44 |
},
|
| 45 |
{
|
| 46 |
"path": "lab/paths.py",
|
|
@@ -54,8 +54,8 @@
|
|
| 54 |
},
|
| 55 |
{
|
| 56 |
"path": "lab/ui.py",
|
| 57 |
-
"bytes":
|
| 58 |
-
"sha256": "
|
| 59 |
},
|
| 60 |
{
|
| 61 |
"path": "projects/document_rag/data/samples/api_contract.md",
|
|
@@ -129,8 +129,8 @@
|
|
| 129 |
},
|
| 130 |
{
|
| 131 |
"path": "projects/inference_planner/README.md",
|
| 132 |
-
"bytes":
|
| 133 |
-
"sha256": "
|
| 134 |
},
|
| 135 |
{
|
| 136 |
"path": "projects/inference_planner/data/hardware_snapshot.json",
|
|
@@ -144,8 +144,8 @@
|
|
| 144 |
},
|
| 145 |
{
|
| 146 |
"path": "projects/inference_planner/local_model_notes.md",
|
| 147 |
-
"bytes":
|
| 148 |
-
"sha256": "
|
| 149 |
},
|
| 150 |
{
|
| 151 |
"path": "projects/inference_planner/outputs/live_agent_smoke.json",
|
|
@@ -184,8 +184,8 @@
|
|
| 184 |
},
|
| 185 |
{
|
| 186 |
"path": "projects/inference_planner/planner/local_model.py",
|
| 187 |
-
"bytes":
|
| 188 |
-
"sha256": "
|
| 189 |
},
|
| 190 |
{
|
| 191 |
"path": "projects/inference_planner/planner/service.py",
|
|
@@ -204,8 +204,8 @@
|
|
| 204 |
},
|
| 205 |
{
|
| 206 |
"path": "projects/inference_planner/tests/test_local_model.py",
|
| 207 |
-
"bytes":
|
| 208 |
-
"sha256": "
|
| 209 |
},
|
| 210 |
{
|
| 211 |
"path": "projects/llm_evaluation_workbench/data/rubrics/bias_checks.md",
|
|
@@ -428,8 +428,8 @@
|
|
| 428 |
},
|
| 429 |
{
|
| 430 |
"path": "tests/test_local_planner.py",
|
| 431 |
-
"bytes":
|
| 432 |
-
"sha256": "
|
| 433 |
},
|
| 434 |
{
|
| 435 |
"path": "tests/test_publication.py",
|
|
|
|
| 1 |
{
|
| 2 |
"schema_version": "public-ai-lab-release/1",
|
| 3 |
+
"created_at": "2026-08-31T01:46:02.860848+00:00",
|
| 4 |
"owner": "Yogesh18018",
|
| 5 |
"space_sdk": "gradio",
|
| 6 |
"space_hardware": "zero-a10g",
|
|
|
|
| 9 |
"files": [
|
| 10 |
{
|
| 11 |
"path": "LOCAL_QWEN.md",
|
| 12 |
+
"bytes": 5032,
|
| 13 |
+
"sha256": "1b9ac653e56ff25be72e1a52588492d531395942ad73772eb2c5ea698def3288"
|
| 14 |
},
|
| 15 |
{
|
| 16 |
"path": "PUBLICATION.md",
|
|
|
|
| 19 |
},
|
| 20 |
{
|
| 21 |
"path": "README.md",
|
| 22 |
+
"bytes": 7158,
|
| 23 |
+
"sha256": "82b625850f3e9ffc7a02cfa0bf4fe8f9226055e851dd2648a96b370ebf171718"
|
| 24 |
},
|
| 25 |
{
|
| 26 |
"path": "app.py",
|
|
|
|
| 39 |
},
|
| 40 |
{
|
| 41 |
"path": "lab/local_planner.py",
|
| 42 |
+
"bytes": 8812,
|
| 43 |
+
"sha256": "a4620bb148486f7e94b1d9949fa6587caf64d6793a0fd4629fb621e45aa1971d"
|
| 44 |
},
|
| 45 |
{
|
| 46 |
"path": "lab/paths.py",
|
|
|
|
| 54 |
},
|
| 55 |
{
|
| 56 |
"path": "lab/ui.py",
|
| 57 |
+
"bytes": 29412,
|
| 58 |
+
"sha256": "5a34fe8c6b16ac1c944cf6d88c7c8cbc30ef6c3d159b23190c6b790ead6b7b9e"
|
| 59 |
},
|
| 60 |
{
|
| 61 |
"path": "projects/document_rag/data/samples/api_contract.md",
|
|
|
|
| 129 |
},
|
| 130 |
{
|
| 131 |
"path": "projects/inference_planner/README.md",
|
| 132 |
+
"bytes": 6790,
|
| 133 |
+
"sha256": "c7f4ed42d05b0f5cea939547f7b7f4b30fdc495733b556b3748ef8fb844f40ec"
|
| 134 |
},
|
| 135 |
{
|
| 136 |
"path": "projects/inference_planner/data/hardware_snapshot.json",
|
|
|
|
| 144 |
},
|
| 145 |
{
|
| 146 |
"path": "projects/inference_planner/local_model_notes.md",
|
| 147 |
+
"bytes": 6871,
|
| 148 |
+
"sha256": "e941b8cd21e05197c4d2d6b5d9b3fe0c9018a96b2d7a4cff84f192f5f4affc09"
|
| 149 |
},
|
| 150 |
{
|
| 151 |
"path": "projects/inference_planner/outputs/live_agent_smoke.json",
|
|
|
|
| 184 |
},
|
| 185 |
{
|
| 186 |
"path": "projects/inference_planner/planner/local_model.py",
|
| 187 |
+
"bytes": 18306,
|
| 188 |
+
"sha256": "25b83f2cc02093c6ef60defc608e79e50763c915d5fceab8141b1be9f49a6ec1"
|
| 189 |
},
|
| 190 |
{
|
| 191 |
"path": "projects/inference_planner/planner/service.py",
|
|
|
|
| 204 |
},
|
| 205 |
{
|
| 206 |
"path": "projects/inference_planner/tests/test_local_model.py",
|
| 207 |
+
"bytes": 25155,
|
| 208 |
+
"sha256": "ba7f0ce065582b406b33878642f77a89228adaebac68c2a32bc21d40082fccf0"
|
| 209 |
},
|
| 210 |
{
|
| 211 |
"path": "projects/llm_evaluation_workbench/data/rubrics/bias_checks.md",
|
|
|
|
| 428 |
},
|
| 429 |
{
|
| 430 |
"path": "tests/test_local_planner.py",
|
| 431 |
+
"bytes": 18459,
|
| 432 |
+
"sha256": "5bbf729865c1984c076162c6edf39baa3a93e31ab257d6fb085849cb167e946d"
|
| 433 |
},
|
| 434 |
{
|
| 435 |
"path": "tests/test_publication.py",
|
README.md
CHANGED
|
@@ -54,8 +54,8 @@ Native PyTorch training code is included under `projects/newssnap/src/models`. A
|
|
| 54 |
- The app does not write visitor text to its project files or a persistent vector index. Each retrieval request uses a unique ephemeral collection and deletes it in a `finally` block.
|
| 55 |
- There are no file uploads or downloadable server paths. The launch configuration blocks the application directory from Gradio file serving.
|
| 56 |
- Results and input fields can remain in the visitor's browser until cleared or the page is closed. Hosting infrastructure may retain operational request metadata. This is not a confidential-data service.
|
| 57 |
-
-
|
| 58 |
-
-
|
| 59 |
- The planner has an optional owner-only assistant. It remains unavailable unless the deployment owner separately configures the required model, key, enable flag, and access token. It requires the owner's code and explicit per-request send consent. No secrets are included in this release. The code field is cleared after a request.
|
| 60 |
- GPU use follows Hugging Face's normal ZeroGPU queue and visitor quotas. Queueing time is not included in the measured classification latency.
|
| 61 |
|
|
@@ -69,7 +69,7 @@ The platform also installs `gradio[oauth,mcp]==6.10.0`. That version's MCP extra
|
|
| 69 |
|
| 70 |
`suggested_hardware` is descriptive metadata. It does **not** allocate hardware. The guarded publication script separately requests `SpaceHardware.ZERO_A10G`, whose API value is `zero-a10g`.
|
| 71 |
|
| 72 |
-
For local tests, create an isolated Python 3.12 environment, install `requirements-dev.txt`, and install `torch==2.8.0` from the official CPU wheel index. Run `python -m pytest`. The app also runs on CPU for local checks.
|
| 73 |
|
| 74 |
## Source layout
|
| 75 |
|
|
|
|
| 54 |
- The app does not write visitor text to its project files or a persistent vector index. Each retrieval request uses a unique ephemeral collection and deletes it in a `finally` block.
|
| 55 |
- There are no file uploads or downloadable server paths. The launch configuration blocks the application directory from Gradio file serving.
|
| 56 |
- Results and input fields can remain in the visitor's browser until cleared or the page is closed. Hosting infrastructure may retain operational request metadata. This is not a confidential-data service.
|
| 57 |
+
- External-provider generation is disabled in the public deployment. No API-key field is exposed.
|
| 58 |
+
- The key-free Qwen planner uses the same typed cost tools and renders only validated catalog selections. It is off by default in source and enabled in the reviewed Space. See `LOCAL_QWEN.md` for its startup guards, limits, and current verification status.
|
| 59 |
- The planner has an optional owner-only assistant. It remains unavailable unless the deployment owner separately configures the required model, key, enable flag, and access token. It requires the owner's code and explicit per-request send consent. No secrets are included in this release. The code field is cleared after a request.
|
| 60 |
- GPU use follows Hugging Face's normal ZeroGPU queue and visitor quotas. Queueing time is not included in the measured classification latency.
|
| 61 |
|
|
|
|
| 69 |
|
| 70 |
`suggested_hardware` is descriptive metadata. It does **not** allocate hardware. The guarded publication script separately requests `SpaceHardware.ZERO_A10G`, whose API value is `zero-a10g`.
|
| 71 |
|
| 72 |
+
For local tests, create an isolated Python 3.12 environment, install `requirements-dev.txt`, and install `torch==2.8.0` from the official CPU wheel index. Run `python -m pytest`. The app also runs on CPU for local checks. The hosted classifier passed anonymous API and browser checks on ZeroGPU on August 31, 2026 UTC. Recheck inference after changing its model or runtime.
|
| 73 |
|
| 74 |
## Source layout
|
| 75 |
|
lab/local_planner.py
CHANGED
|
@@ -3,6 +3,9 @@
|
|
| 3 |
import logging
|
| 4 |
import os
|
| 5 |
from threading import Lock
|
|
|
|
|
|
|
|
|
|
| 6 |
|
| 7 |
from .classification import zero_gpu_enabled
|
| 8 |
from .services import pricing_catalog
|
|
@@ -12,7 +15,34 @@ _startup_attempted = False
|
|
| 12 |
_startup_lock = Lock()
|
| 13 |
_run_lock = Lock()
|
| 14 |
|
| 15 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
"The local model input exceeded the token limit or batch format.": "input_token_limit",
|
| 17 |
"The local model output was truncated. No partial tool call was accepted.": "incomplete_generation",
|
| 18 |
"The local model returned invalid tool-call JSON.": "invalid_tool_json",
|
|
@@ -26,13 +56,76 @@ FAILURE_CODES = {
|
|
| 26 |
"The optional local Qwen planner is not ready.": "runtime_not_ready",
|
| 27 |
}
|
| 28 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
|
| 30 |
def log_local_failure(error: Exception) -> None:
|
| 31 |
"""Log a class and an allowlisted reason code, never prompts or exception bodies."""
|
| 32 |
-
reason =
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
|
| 37 |
|
| 38 |
def local_qwen_requested() -> bool:
|
|
|
|
| 3 |
import logging
|
| 4 |
import os
|
| 5 |
from threading import Lock
|
| 6 |
+
from typing import Literal
|
| 7 |
+
|
| 8 |
+
from pydantic import BaseModel, ConfigDict, ValidationError
|
| 9 |
|
| 10 |
from .classification import zero_gpu_enabled
|
| 11 |
from .services import pricing_catalog
|
|
|
|
| 15 |
_startup_lock = Lock()
|
| 16 |
_run_lock = Lock()
|
| 17 |
|
| 18 |
+
FailureReason = Literal[
|
| 19 |
+
"input_token_limit",
|
| 20 |
+
"incomplete_generation",
|
| 21 |
+
"invalid_tool_json",
|
| 22 |
+
"missing_first_tool",
|
| 23 |
+
"incomplete_tool_block",
|
| 24 |
+
"invalid_final_selection",
|
| 25 |
+
"invalid_tool_call",
|
| 26 |
+
"turn_limit",
|
| 27 |
+
"agent_time_limit",
|
| 28 |
+
"process_call_budget",
|
| 29 |
+
"runtime_not_ready",
|
| 30 |
+
"invalid_request",
|
| 31 |
+
"invalid_result",
|
| 32 |
+
"unclassified_runtime_error",
|
| 33 |
+
]
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
class LocalPlannerFailure(BaseModel):
|
| 37 |
+
"""Only this small, closed failure record may cross the GPU process boundary."""
|
| 38 |
+
|
| 39 |
+
model_config = ConfigDict(extra="forbid", strict=True, frozen=True)
|
| 40 |
+
|
| 41 |
+
status: Literal["error"]
|
| 42 |
+
reason: FailureReason
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
FAILURE_CODES: dict[str, FailureReason] = {
|
| 46 |
"The local model input exceeded the token limit or batch format.": "input_token_limit",
|
| 47 |
"The local model output was truncated. No partial tool call was accepted.": "incomplete_generation",
|
| 48 |
"The local model returned invalid tool-call JSON.": "invalid_tool_json",
|
|
|
|
| 56 |
"The optional local Qwen planner is not ready.": "runtime_not_ready",
|
| 57 |
}
|
| 58 |
|
| 59 |
+
FAILURE_MESSAGES: dict[FailureReason, str] = {
|
| 60 |
+
"input_token_limit": "The request exceeded the local model's input-token limit.",
|
| 61 |
+
"incomplete_generation": "The local model stopped before completing its response.",
|
| 62 |
+
"invalid_tool_json": "The local model returned a tool call that failed JSON validation.",
|
| 63 |
+
"missing_first_tool": "The local model did not select a required planning tool.",
|
| 64 |
+
"incomplete_tool_block": "The local model returned an incomplete tool call.",
|
| 65 |
+
"invalid_final_selection": "The model's selected options could not be verified against the tool results.",
|
| 66 |
+
"invalid_tool_call": "The local model selected an invalid or repeated planning tool.",
|
| 67 |
+
"turn_limit": "The local model reached its three-turn limit.",
|
| 68 |
+
"agent_time_limit": "The local comparison exceeded its time limit.",
|
| 69 |
+
"process_call_budget": "The local model's process call allowance has been reached.",
|
| 70 |
+
"runtime_not_ready": "The local Qwen runtime is not ready.",
|
| 71 |
+
"invalid_request": "Check the question and workload controls. Some values were not accepted.",
|
| 72 |
+
"invalid_result": "The local runtime returned an invalid result.",
|
| 73 |
+
"unclassified_runtime_error": "The local runtime could not complete this request.",
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def local_failure(error: Exception) -> LocalPlannerFailure:
|
| 78 |
+
"""Match known literal arguments without formatting an exception or its cause."""
|
| 79 |
+
reason: FailureReason = "unclassified_runtime_error"
|
| 80 |
+
if isinstance(error, ValidationError):
|
| 81 |
+
reason = "invalid_request"
|
| 82 |
+
else:
|
| 83 |
+
arguments = BaseException.args.__get__(error)
|
| 84 |
+
if len(arguments) == 1 and type(arguments[0]) is str:
|
| 85 |
+
reason = FAILURE_CODES.get(arguments[0], reason)
|
| 86 |
+
return LocalPlannerFailure(status="error", reason=reason)
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def local_failure_status(failure: LocalPlannerFailure) -> str:
|
| 90 |
+
return (
|
| 91 |
+
f"No comparison was accepted. {FAILURE_MESSAGES[failure.reason]} "
|
| 92 |
+
"The cost calculators remain available."
|
| 93 |
+
)
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def read_local_failure(record: object) -> LocalPlannerFailure | None:
|
| 97 |
+
"""Reject malformed envelopes before treating a result as a successful comparison."""
|
| 98 |
+
invalid = LocalPlannerFailure(status="error", reason="invalid_result")
|
| 99 |
+
if type(record) is not dict:
|
| 100 |
+
return invalid
|
| 101 |
+
if any(key in record for key in ("status", "reason", "error")):
|
| 102 |
+
try:
|
| 103 |
+
return LocalPlannerFailure.model_validate(record)
|
| 104 |
+
except ValidationError:
|
| 105 |
+
return invalid
|
| 106 |
+
if type(record.get("answer")) is not str or not record["answer"].strip():
|
| 107 |
+
return invalid
|
| 108 |
+
return None
|
| 109 |
+
|
| 110 |
|
| 111 |
def log_local_failure(error: Exception) -> None:
|
| 112 |
"""Log a class and an allowlisted reason code, never prompts or exception bodies."""
|
| 113 |
+
reason = local_failure(error).reason
|
| 114 |
+
error_name = type(error).__name__
|
| 115 |
+
if error_name not in {
|
| 116 |
+
"AgentError",
|
| 117 |
+
"ValidationError",
|
| 118 |
+
"RuntimeError",
|
| 119 |
+
"ValueError",
|
| 120 |
+
"TypeError",
|
| 121 |
+
"TimeoutError",
|
| 122 |
+
"KeyError",
|
| 123 |
+
"ImportError",
|
| 124 |
+
"MemoryError",
|
| 125 |
+
"OSError",
|
| 126 |
+
}:
|
| 127 |
+
error_name = "Exception"
|
| 128 |
+
logging.getLogger(__name__).warning("Local Qwen request failed (%s; %s).", error_name, reason)
|
| 129 |
|
| 130 |
|
| 131 |
def local_qwen_requested() -> bool:
|
lab/ui.py
CHANGED
|
@@ -17,8 +17,11 @@ from .classification import (
|
|
| 17 |
)
|
| 18 |
from .local_planner import (
|
| 19 |
initialize_local_qwen,
|
|
|
|
|
|
|
| 20 |
log_local_failure,
|
| 21 |
local_qwen_status,
|
|
|
|
| 22 |
run_local_comparison,
|
| 23 |
validate_local_request,
|
| 24 |
)
|
|
@@ -175,7 +178,14 @@ def run_owner_chat(
|
|
| 175 |
|
| 176 |
@spaces.GPU(duration=110)
|
| 177 |
def run_qwen_on_gpu(payload: dict) -> dict:
|
| 178 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 179 |
|
| 180 |
|
| 181 |
def run_public_qwen(
|
|
@@ -201,6 +211,9 @@ def run_public_qwen(
|
|
| 201 |
try:
|
| 202 |
request = validate_local_request(payload, consent)
|
| 203 |
record = run_qwen_on_gpu(request.model_dump())
|
|
|
|
|
|
|
|
|
|
| 204 |
return (
|
| 205 |
"Local Qwen comparison completed. Inspect the source-backed selections below.",
|
| 206 |
record["answer"],
|
|
@@ -208,12 +221,7 @@ def run_public_qwen(
|
|
| 208 |
)
|
| 209 |
except Exception as error:
|
| 210 |
log_local_failure(error)
|
| 211 |
-
return (
|
| 212 |
-
"No comparison was accepted. The local model may be unavailable or may have failed "
|
| 213 |
-
"the format, evidence, token, or time checks. The cost calculators remain available.",
|
| 214 |
-
"",
|
| 215 |
-
None,
|
| 216 |
-
)
|
| 217 |
|
| 218 |
|
| 219 |
@spaces.GPU(duration=20)
|
|
|
|
| 17 |
)
|
| 18 |
from .local_planner import (
|
| 19 |
initialize_local_qwen,
|
| 20 |
+
local_failure,
|
| 21 |
+
local_failure_status,
|
| 22 |
log_local_failure,
|
| 23 |
local_qwen_status,
|
| 24 |
+
read_local_failure,
|
| 25 |
run_local_comparison,
|
| 26 |
validate_local_request,
|
| 27 |
)
|
|
|
|
| 178 |
|
| 179 |
@spaces.GPU(duration=110)
|
| 180 |
def run_qwen_on_gpu(payload: dict) -> dict:
|
| 181 |
+
try:
|
| 182 |
+
return run_local_comparison(payload, consent=True)
|
| 183 |
+
except Exception as error:
|
| 184 |
+
# Spaces can print escaping exceptions with their causes and input details.
|
| 185 |
+
# Return only a validated reason code from inside the GPU callback instead.
|
| 186 |
+
failure = local_failure(error)
|
| 187 |
+
log_local_failure(error)
|
| 188 |
+
return failure.model_dump(mode="json")
|
| 189 |
|
| 190 |
|
| 191 |
def run_public_qwen(
|
|
|
|
| 211 |
try:
|
| 212 |
request = validate_local_request(payload, consent)
|
| 213 |
record = run_qwen_on_gpu(request.model_dump())
|
| 214 |
+
failure = read_local_failure(record)
|
| 215 |
+
if failure is not None:
|
| 216 |
+
return local_failure_status(failure), "", None
|
| 217 |
return (
|
| 218 |
"Local Qwen comparison completed. Inspect the source-backed selections below.",
|
| 219 |
record["answer"],
|
|
|
|
| 221 |
)
|
| 222 |
except Exception as error:
|
| 223 |
log_local_failure(error)
|
| 224 |
+
return local_failure_status(local_failure(error)), "", None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 225 |
|
| 226 |
|
| 227 |
@spaces.GPU(duration=20)
|
projects/inference_planner/README.md
CHANGED
|
@@ -20,7 +20,7 @@ The optional assistant calls three read-only planning tools and selects options
|
|
| 20 |
- **Break-even:** cost-only request threshold. Capacity is calculated only when the user supplies measured requests per second for the exact deployment.
|
| 21 |
- **Tool-calling assistant:** validated function arguments, user-controlled workload inputs, three model turns at most, five tools at most, a fixed provider URL, and an owner access code. Final selections must reference returned options. Unsupported prose and additional claims are rejected.
|
| 22 |
|
| 23 |
-
The public Gradio interface is in `../public_ai_lab`. Its calculation tools work without a model-provider secret.
|
| 24 |
|
| 25 |
## Sources and freshness
|
| 26 |
|
|
|
|
| 20 |
- **Break-even:** cost-only request threshold. Capacity is calculated only when the user supplies measured requests per second for the exact deployment.
|
| 21 |
- **Tool-calling assistant:** validated function arguments, user-controlled workload inputs, three model turns at most, five tools at most, a fixed provider URL, and an owner access code. Final selections must reference returned options. Unsupported prose and additional claims are rejected.
|
| 22 |
|
| 23 |
+
The public Gradio interface is in `../public_ai_lab`. Its calculation tools work without a model-provider secret. A separately enabled Qwen backend runs on free ZeroGPU. The optional NVIDIA backend requires private owner configuration. A replay or a cost table is never presented as a live LLM response. See the lab verification record for hosted chat status.
|
| 24 |
|
| 25 |
## Sources and freshness
|
| 26 |
|
projects/inference_planner/local_model_notes.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
# Optional local Qwen backend
|
| 2 |
|
| 3 |
-
Status: implemented and tested with injected doubles on August 30, 2026.
|
| 4 |
|
| 5 |
## Model identity
|
| 6 |
|
|
@@ -42,14 +42,16 @@ result = run_agent(ChatRequest.model_validate(user_payload), client)
|
|
| 42 |
- Maximum three tool calls per completion. The unchanged agent also enforces five tools total, three model turns, repeated-call rejection, typed scenario values and source-grounded final selections.
|
| 43 |
- The process-local daily `CallBudget` is retained. It is not a durable, per-user or provider-side quota and resets with a new process. Reuse one client and serialize requests in the wrapper.
|
| 44 |
- No free-form model prose becomes a validated answer. The current agent validates the model's final `Selection` and renders it from recorded tool results.
|
|
|
|
|
|
|
| 45 |
|
| 46 |
## Loading and ZeroGPU handoff
|
| 47 |
|
| 48 |
-
The wrapper
|
| 49 |
|
| 50 |
Current [official ZeroGPU documentation](https://huggingface.co/docs/hub/spaces-zerogpu) lists the Gradio SDK and PyTorch 2.8.0 onward. An older local CPU PyTorch environment is not a ZeroGPU compatibility test. Follow the documented startup CUDA-placement approach under `spaces` emulation, then decorate the GPU-dependent callback with `spaces.GPU`. Serialize this callback with the classifier callback if they share GPU resources. Set its duration for the whole bounded agent loop, not for one token-generation call.
|
| 51 |
|
| 52 |
-
|
| 53 |
|
| 54 |
## Verification
|
| 55 |
|
|
@@ -62,15 +64,15 @@ ruff check planner/local_model.py tests/test_local_model.py
|
|
| 62 |
ruff format --check planner/local_model.py tests/test_local_model.py
|
| 63 |
```
|
| 64 |
|
| 65 |
-
Observed with
|
| 66 |
|
| 67 |
-
- Adapter tests: **
|
| 68 |
-
-
|
| 69 |
- Ruff: all checks passed. Both files are formatted.
|
| 70 |
|
| 71 |
-
The tests use explicit tensor, tokenizer and model doubles. They verify interface wiring and safeguards, including the
|
| 72 |
|
| 73 |
Source SHA-256 values at verification:
|
| 74 |
|
| 75 |
-
- `planner/local_model.py`: `
|
| 76 |
-
- `tests/test_local_model.py`: `
|
|
|
|
| 1 |
# Optional local Qwen backend
|
| 2 |
|
| 3 |
+
Status: implemented and tested with injected doubles on August 30–31, 2026. The public ZeroGPU wrapper loaded the pinned Qwen model. Its first real comparison repeated a tool call and was stopped. A second run reached final validation but added a JSON wrapper and was rejected. The revised response instructions and narrow format normalization need a hosted check. See the lab verification record for current public status.
|
| 4 |
|
| 5 |
## Model identity
|
| 6 |
|
|
|
|
| 42 |
- Maximum three tool calls per completion. The unchanged agent also enforces five tools total, three model turns, repeated-call rejection, typed scenario values and source-grounded final selections.
|
| 43 |
- The process-local daily `CallBudget` is retained. It is not a durable, per-user or provider-side quota and resets with a new process. Reuse one client and serialize requests in the wrapper.
|
| 44 |
- No free-form model prose becomes a validated answer. The current agent validates the model's final `Selection` and renders it from recorded tool results.
|
| 45 |
+
- After tool results, a checkpoint lists completed effective calls, returned candidate IDs, allowed codes, and four response fields. The model still chooses the IDs. A second turn can request a distinct missing calculation. The third turn has no tools and must finish. Original repeat rejection remains enabled.
|
| 46 |
+
- A single observed `selection_schema` envelope can be unwrapped only when its value already contains all four fields and passes strict Selection validation. Nested wrappers, extra fields, omitted fields, and invalid values are rejected. No defaults or selections are inserted. Usage records flag an actual normalization. The original agent still rejects unknown IDs and empty selections when candidates exist.
|
| 47 |
|
| 48 |
## Loading and ZeroGPU handoff
|
| 49 |
|
| 50 |
+
The wrapper implements guarded startup loading with the pinned revision for both model and tokenizer, `trust_remote_code=False`, safetensors and reviewed dependencies. The adapter receives those objects and does not itself authenticate their checkpoint provenance. Model allocation stays outside this adapter.
|
| 51 |
|
| 52 |
Current [official ZeroGPU documentation](https://huggingface.co/docs/hub/spaces-zerogpu) lists the Gradio SDK and PyTorch 2.8.0 onward. An older local CPU PyTorch environment is not a ZeroGPU compatibility test. Follow the documented startup CUDA-placement approach under `spaces` emulation, then decorate the GPU-dependent callback with `spaces.GPU`. Serialize this callback with the classifier callback if they share GPU resources. Set its duration for the whole bounded agent loop, not for one token-generation call.
|
| 53 |
|
| 54 |
+
The lab's account was accepted for a free ZeroGPU Space. Native visitor quotas and queueing still apply. No paid hardware was requested. No hosted NVIDIA key is needed by this adapter.
|
| 55 |
|
| 56 |
## Verification
|
| 57 |
|
|
|
|
| 64 |
ruff format --check planner/local_model.py tests/test_local_model.py
|
| 65 |
```
|
| 66 |
|
| 67 |
+
Observed with the planner environment on August 30–31, 2026:
|
| 68 |
|
| 69 |
+
- Adapter tests: **93 passed**, including follow-up, exact-envelope, and filter-guidance regressions.
|
| 70 |
+
- Complete planner suite: **384 passed**.
|
| 71 |
- Ruff: all checks passed. Both files are formatted.
|
| 72 |
|
| 73 |
+
The adapter tests use explicit tensor, tokenizer and model doubles. They verify interface wiring and safeguards, including the agent's tool-to-selection round trip. They do not establish Qwen's recommendation quality. The hosted checks use separate non-sensitive examples and their results are reported separately.
|
| 74 |
|
| 75 |
Source SHA-256 values at verification:
|
| 76 |
|
| 77 |
+
- `planner/local_model.py`: `25b83f2cc02093c6ef60defc608e79e50763c915d5fceab8141b1be9f49a6ec1`
|
| 78 |
+
- `tests/test_local_model.py`: `ba7f0ce065582b406b33878642f77a89228adaebac68c2a32bc21d40082fccf0`
|
projects/inference_planner/planner/local_model.py
CHANGED
|
@@ -4,7 +4,7 @@ import json
|
|
| 4 |
import math
|
| 5 |
from uuid import uuid4
|
| 6 |
|
| 7 |
-
from .agent import AgentError, CallBudget
|
| 8 |
from .contracts import Selection
|
| 9 |
from .tools import TOOL_MODELS, tool_schemas
|
| 10 |
|
|
@@ -14,7 +14,10 @@ OPEN_CALL, CLOSE_CALL = "<tool_call>", "</tool_call>"
|
|
| 14 |
FIRST_TURN = (
|
| 15 |
"On this first turn, call at least one of the provided planning tools. "
|
| 16 |
"Use the exact <tool_call> JSON format from the tool template. "
|
| 17 |
-
"Do not give a final selection before reading a tool result."
|
|
|
|
|
|
|
|
|
|
| 18 |
)
|
| 19 |
FOLLOW_UP = (
|
| 20 |
"The planning tools above have finished. Do not repeat a completed call with the same "
|
|
@@ -23,11 +26,16 @@ FOLLOW_UP = (
|
|
| 23 |
"An additional tool is allowed only for a distinct comparison the user requested that is "
|
| 24 |
"still missing, or a requested break-even calculation that has not been computed. "
|
| 25 |
"Use the exact tool-call format only for that distinct missing step. "
|
| 26 |
-
"
|
| 27 |
-
"
|
| 28 |
-
"
|
| 29 |
-
"
|
| 30 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
)
|
| 32 |
FINAL_TURN = (
|
| 33 |
"This is the final response turn. No further tools are available. "
|
|
@@ -197,30 +205,47 @@ def _followup_checkpoint(messages):
|
|
| 197 |
if not completed:
|
| 198 |
return None, False
|
| 199 |
properties = Selection.model_json_schema()["properties"]
|
| 200 |
-
for field in properties.values():
|
| 201 |
-
field.pop("title", None)
|
| 202 |
-
for category, identifiers in candidates.items():
|
| 203 |
-
if identifiers:
|
| 204 |
-
properties[category]["items"]["enum"] = identifiers
|
| 205 |
-
else:
|
| 206 |
-
properties[category]["maxItems"] = 0
|
| 207 |
-
schema = {
|
| 208 |
-
"type": "object",
|
| 209 |
-
"additionalProperties": False,
|
| 210 |
-
"required": list(properties),
|
| 211 |
-
"properties": properties,
|
| 212 |
-
}
|
| 213 |
final_only = len(rounds) >= 2
|
| 214 |
instruction = FOLLOW_UP + ("\n" + FINAL_TURN if final_only else "")
|
| 215 |
-
reference = {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 216 |
return {
|
| 217 |
"role": "user",
|
| 218 |
-
"content":
|
| 219 |
-
|
| 220 |
-
|
|
|
|
|
|
|
|
|
|
| 221 |
}, final_only
|
| 222 |
|
| 223 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 224 |
class LocalQwenClient:
|
| 225 |
"""A run_agent client using caller-owned model/tokenizer objects.
|
| 226 |
|
|
@@ -363,6 +388,7 @@ class LocalQwenClient:
|
|
| 363 |
message = parse_qwen_output(text, require_tool=first)
|
| 364 |
if final_only and message["tool_calls"]:
|
| 365 |
raise AgentError("The local model requested another tool on the final selection turn.")
|
|
|
|
| 366 |
usage = {
|
| 367 |
"prompt_tokens": input_tokens,
|
| 368 |
"completion_tokens": len(new_tokens),
|
|
@@ -370,4 +396,6 @@ class LocalQwenClient:
|
|
| 370 |
"backend": "local_transformers",
|
| 371 |
"model_revision": self.revision,
|
| 372 |
}
|
|
|
|
|
|
|
| 373 |
return message, usage
|
|
|
|
| 4 |
import math
|
| 5 |
from uuid import uuid4
|
| 6 |
|
| 7 |
+
from .agent import AgentError, CallBudget, strict_json
|
| 8 |
from .contracts import Selection
|
| 9 |
from .tools import TOOL_MODELS, tool_schemas
|
| 10 |
|
|
|
|
| 14 |
FIRST_TURN = (
|
| 15 |
"On this first turn, call at least one of the provided planning tools. "
|
| 16 |
"Use the exact <tool_call> JSON format from the tool template. "
|
| 17 |
+
"Do not give a final selection before reading a tool result. "
|
| 18 |
+
"filters.model_query is only a literal provider or model-name filter. Keep it empty unless "
|
| 19 |
+
"the user names a provider or model. Do not put 'low-cost' or 'tool calling' in model_query. "
|
| 20 |
+
"A tool-support requirement belongs in filters.require_tools."
|
| 21 |
)
|
| 22 |
FOLLOW_UP = (
|
| 23 |
"The planning tools above have finished. Do not repeat a completed call with the same "
|
|
|
|
| 26 |
"An additional tool is allowed only for a distinct comparison the user requested that is "
|
| 27 |
"still missing, or a requested break-even calculation that has not been computed. "
|
| 28 |
"Use the exact tool-call format only for that distinct missing step. "
|
| 29 |
+
"For a final response, output one JSON object with exactly four top-level fields: "
|
| 30 |
+
"api_model_ids, compute_ids, tradeoffs, next_step. Do not add an outer wrapper. "
|
| 31 |
+
"api_model_ids: an array of up to 5 exact returned API IDs that you choose. "
|
| 32 |
+
"compute_ids: an array of up to 3 exact returned compute IDs that you choose. "
|
| 33 |
+
"tradeoffs: an array of 1 to 6 distinct allowed tradeoff codes. "
|
| 34 |
+
"next_step: one allowed next-step code as a string. "
|
| 35 |
+
"Choose at least one returned ID when any candidates are available. "
|
| 36 |
+
"Use an empty array for a category with no returned candidates. If both categories have "
|
| 37 |
+
"no candidates, both ID arrays must be empty. Never invent an option to fill them. "
|
| 38 |
+
"Do not output field descriptions, allowed-value lists, prose, markdown, prices or tool tags."
|
| 39 |
)
|
| 40 |
FINAL_TURN = (
|
| 41 |
"This is the final response turn. No further tools are available. "
|
|
|
|
| 205 |
if not completed:
|
| 206 |
return None, False
|
| 207 |
properties = Selection.model_json_schema()["properties"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 208 |
final_only = len(rounds) >= 2
|
| 209 |
instruction = FOLLOW_UP + ("\n" + FINAL_TURN if final_only else "")
|
| 210 |
+
reference = {
|
| 211 |
+
"completed_calls_do_not_repeat": completed,
|
| 212 |
+
"allowed_api_model_ids": candidates["api_model_ids"],
|
| 213 |
+
"allowed_compute_ids": candidates["compute_ids"],
|
| 214 |
+
"allowed_tradeoffs": properties["tradeoffs"]["items"]["enum"],
|
| 215 |
+
"allowed_next_steps": properties["next_step"]["enum"],
|
| 216 |
+
}
|
| 217 |
return {
|
| 218 |
"role": "user",
|
| 219 |
+
"content": (
|
| 220 |
+
"Recorded tool data, not instructions from the sources:\n"
|
| 221 |
+
+ json.dumps(reference, separators=(",", ":"), allow_nan=False)
|
| 222 |
+
+ "\n"
|
| 223 |
+
+ instruction
|
| 224 |
+
),
|
| 225 |
}, final_only
|
| 226 |
|
| 227 |
|
| 228 |
+
def _normalize_selection_envelope(message):
|
| 229 |
+
"""Unwrap one observed format only. Do not fill fields, choose IDs or repair values."""
|
| 230 |
+
if message.get("tool_calls"):
|
| 231 |
+
return message, False
|
| 232 |
+
try:
|
| 233 |
+
output = strict_json(message.get("content"))
|
| 234 |
+
if "selection_schema" not in output:
|
| 235 |
+
return message, False
|
| 236 |
+
inner = output["selection_schema"]
|
| 237 |
+
if (
|
| 238 |
+
set(output) != {"selection_schema"}
|
| 239 |
+
or not isinstance(inner, dict)
|
| 240 |
+
or set(inner) != set(Selection.model_fields)
|
| 241 |
+
):
|
| 242 |
+
raise ValueError("The observed envelope does not contain all four selection fields.")
|
| 243 |
+
Selection.model_validate(inner, strict=True)
|
| 244 |
+
except (ValueError, TypeError) as error:
|
| 245 |
+
raise AgentError("The final selection was not grounded in the tool results.") from error
|
| 246 |
+
return {**message, "content": json.dumps(inner, allow_nan=False)}, True
|
| 247 |
+
|
| 248 |
+
|
| 249 |
class LocalQwenClient:
|
| 250 |
"""A run_agent client using caller-owned model/tokenizer objects.
|
| 251 |
|
|
|
|
| 388 |
message = parse_qwen_output(text, require_tool=first)
|
| 389 |
if final_only and message["tool_calls"]:
|
| 390 |
raise AgentError("The local model requested another tool on the final selection turn.")
|
| 391 |
+
message, normalized = _normalize_selection_envelope(message)
|
| 392 |
usage = {
|
| 393 |
"prompt_tokens": input_tokens,
|
| 394 |
"completion_tokens": len(new_tokens),
|
|
|
|
| 396 |
"backend": "local_transformers",
|
| 397 |
"model_revision": self.revision,
|
| 398 |
}
|
| 399 |
+
if normalized:
|
| 400 |
+
usage["format_normalization"] = "unwrapped_single_selection_schema_envelope"
|
| 401 |
return message, usage
|
projects/inference_planner/tests/test_local_model.py
CHANGED
|
@@ -439,7 +439,7 @@ def completed_history(name="compare_api_options", arguments=None, result=None, c
|
|
| 439 |
def checkpoint_reference(tokenizer):
|
| 440 |
message = tokenizer.calls[-1][0][-1]
|
| 441 |
assert message["role"] == "user"
|
| 442 |
-
return json.loads(message["content"].
|
| 443 |
|
| 444 |
|
| 445 |
def test_followup_uses_only_completed_returned_ids_and_effective_arguments():
|
|
@@ -463,9 +463,10 @@ def test_followup_uses_only_completed_returned_ids_and_effective_arguments():
|
|
| 463 |
assert reference["completed_calls_do_not_repeat"] == [
|
| 464 |
{"name": "compare_api_options", "effective_arguments": arguments}
|
| 465 |
]
|
| 466 |
-
|
| 467 |
-
assert
|
| 468 |
-
assert
|
|
|
|
| 469 |
assert "invented/user-requested" not in json.dumps(reference)
|
| 470 |
assert "Untrusted prose" not in json.dumps(reference)
|
| 471 |
assert "final Selection JSON NOW" in tokenizer.calls[-1][0][-1]["content"]
|
|
@@ -493,10 +494,14 @@ def test_third_turn_is_schema_only_and_does_not_prefill_a_selection():
|
|
| 493 |
reference = checkpoint_reference(tokenizer)
|
| 494 |
assert FINAL_TURN in tokenizer.calls[-1][0][-1]["content"]
|
| 495 |
assert tokenizer.calls[-1][1]["tools"] == []
|
| 496 |
-
assert reference["
|
| 497 |
-
|
| 498 |
-
|
| 499 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 500 |
assert output["content"] == json.dumps(SELECTION) # Unchanged generated text, not a fallback.
|
| 501 |
|
| 502 |
|
|
@@ -540,9 +545,9 @@ def test_break_even_ids_are_grounded_in_its_returned_api_and_compute_rows():
|
|
| 540 |
result={"api": {"model_id": "fixture/api"}, "compute": {"id": "fixture/compute"}},
|
| 541 |
)
|
| 542 |
)
|
| 543 |
-
|
| 544 |
-
assert
|
| 545 |
-
assert
|
| 546 |
|
| 547 |
|
| 548 |
def test_invalid_completed_record_fails_before_generation():
|
|
@@ -552,3 +557,98 @@ def test_invalid_completed_record_fails_before_generation():
|
|
| 552 |
with pytest.raises(AgentError, match="invalid completed tool records"):
|
| 553 |
client.complete(messages)
|
| 554 |
assert model.calls == []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 439 |
def checkpoint_reference(tokenizer):
|
| 440 |
message = tokenizer.calls[-1][0][-1]
|
| 441 |
assert message["role"] == "user"
|
| 442 |
+
return json.loads(message["content"].split("\n", 2)[1])
|
| 443 |
|
| 444 |
|
| 445 |
def test_followup_uses_only_completed_returned_ids_and_effective_arguments():
|
|
|
|
| 463 |
assert reference["completed_calls_do_not_repeat"] == [
|
| 464 |
{"name": "compare_api_options", "effective_arguments": arguments}
|
| 465 |
]
|
| 466 |
+
assert reference["allowed_api_model_ids"] == ["fixture/returned", "fixture/second"]
|
| 467 |
+
assert reference["allowed_compute_ids"] == []
|
| 468 |
+
assert "selection_schema" not in tokenizer.calls[-1][0][-1]["content"]
|
| 469 |
+
assert "exactly four top-level fields" in tokenizer.calls[-1][0][-1]["content"]
|
| 470 |
assert "invented/user-requested" not in json.dumps(reference)
|
| 471 |
assert "Untrusted prose" not in json.dumps(reference)
|
| 472 |
assert "final Selection JSON NOW" in tokenizer.calls[-1][0][-1]["content"]
|
|
|
|
| 494 |
reference = checkpoint_reference(tokenizer)
|
| 495 |
assert FINAL_TURN in tokenizer.calls[-1][0][-1]["content"]
|
| 496 |
assert tokenizer.calls[-1][1]["tools"] == []
|
| 497 |
+
assert reference["allowed_compute_ids"] == ["fixture/gpu"]
|
| 498 |
+
assert set(reference) == {
|
| 499 |
+
"completed_calls_do_not_repeat",
|
| 500 |
+
"allowed_api_model_ids",
|
| 501 |
+
"allowed_compute_ids",
|
| 502 |
+
"allowed_tradeoffs",
|
| 503 |
+
"allowed_next_steps",
|
| 504 |
+
}
|
| 505 |
assert output["content"] == json.dumps(SELECTION) # Unchanged generated text, not a fallback.
|
| 506 |
|
| 507 |
|
|
|
|
| 545 |
result={"api": {"model_id": "fixture/api"}, "compute": {"id": "fixture/compute"}},
|
| 546 |
)
|
| 547 |
)
|
| 548 |
+
reference = checkpoint_reference(tokenizer)
|
| 549 |
+
assert reference["allowed_api_model_ids"] == ["fixture/api"]
|
| 550 |
+
assert reference["allowed_compute_ids"] == ["fixture/compute"]
|
| 551 |
|
| 552 |
|
| 553 |
def test_invalid_completed_record_fails_before_generation():
|
|
|
|
| 557 |
with pytest.raises(AgentError, match="invalid completed tool records"):
|
| 558 |
client.complete(messages)
|
| 559 |
assert model.calls == []
|
| 560 |
+
|
| 561 |
+
|
| 562 |
+
def test_first_turn_distinguishes_literal_name_filters_from_capability_filters():
|
| 563 |
+
client, _, tokenizer = make_client()
|
| 564 |
+
client.complete([{"role": "user", "content": "Compare low-cost tool calling."}], first=True)
|
| 565 |
+
prompt = tokenizer.calls[0][0][0]["content"]
|
| 566 |
+
assert "literal provider or model-name filter" in prompt
|
| 567 |
+
assert "Keep it empty unless" in prompt
|
| 568 |
+
assert "Do not put 'low-cost' or 'tool calling' in model_query" in prompt
|
| 569 |
+
assert "filters.require_tools" in prompt
|
| 570 |
+
|
| 571 |
+
|
| 572 |
+
def test_known_single_wrapper_preserves_model_choices_and_records_normalization():
|
| 573 |
+
original = {**SELECTION, "tradeoffs": ["cache_assumptions"], "next_step": "measure_token_usage"}
|
| 574 |
+
client, _, _ = make_client([block(), json.dumps({"selection_schema": original})])
|
| 575 |
+
result = run_agent(ChatRequest(question="Compare this workload"), client, FixtureTools())
|
| 576 |
+
assert result["selection"] == original
|
| 577 |
+
assert (
|
| 578 |
+
result["usage"][-1]["format_normalization"] == "unwrapped_single_selection_schema_envelope"
|
| 579 |
+
)
|
| 580 |
+
assert "$3.2000" in result["answer"]
|
| 581 |
+
|
| 582 |
+
|
| 583 |
+
@pytest.mark.parametrize(
|
| 584 |
+
"payload",
|
| 585 |
+
[
|
| 586 |
+
{"selection_schema": {"selection_schema": SELECTION}},
|
| 587 |
+
{"selection_schema": SELECTION, "extra": "do not discard"},
|
| 588 |
+
{"selection_schema": json.dumps(SELECTION)},
|
| 589 |
+
{"selection_schema": []},
|
| 590 |
+
{"selection_schema": None},
|
| 591 |
+
{"selection_schema": {"type": "object", "properties": {}}},
|
| 592 |
+
{
|
| 593 |
+
"selection_schema": {
|
| 594 |
+
key: value for key, value in SELECTION.items() if key != "api_model_ids"
|
| 595 |
+
}
|
| 596 |
+
},
|
| 597 |
+
{
|
| 598 |
+
"selection_schema": {
|
| 599 |
+
key: value for key, value in SELECTION.items() if key != "next_step"
|
| 600 |
+
}
|
| 601 |
+
},
|
| 602 |
+
{"selection_schema": {**SELECTION, "extra": "do not discard"}},
|
| 603 |
+
{"selection_schema": {**SELECTION, "tradeoffs": []}},
|
| 604 |
+
{"selection_schema": {**SELECTION, "api_model_ids": "fixture/model"}},
|
| 605 |
+
{"selection_schema": {**SELECTION, "next_step": "invented_code"}},
|
| 606 |
+
{"result": SELECTION},
|
| 607 |
+
],
|
| 608 |
+
)
|
| 609 |
+
def test_envelope_normalization_never_repairs_incomplete_or_unrecognized_output(payload):
|
| 610 |
+
client, _, _ = make_client([block(), json.dumps(payload)])
|
| 611 |
+
with pytest.raises(AgentError, match="not grounded"):
|
| 612 |
+
run_agent(ChatRequest(question="Compare this workload"), client, FixtureTools())
|
| 613 |
+
|
| 614 |
+
|
| 615 |
+
def test_duplicate_wrapper_keys_are_not_silently_repaired():
|
| 616 |
+
raw = (
|
| 617 |
+
'{"selection_schema":'
|
| 618 |
+
+ json.dumps(SELECTION)
|
| 619 |
+
+ ',"selection_schema":'
|
| 620 |
+
+ json.dumps(SELECTION)
|
| 621 |
+
+ "}"
|
| 622 |
+
)
|
| 623 |
+
client, _, _ = make_client([block(), raw])
|
| 624 |
+
with pytest.raises(AgentError, match="not grounded"):
|
| 625 |
+
run_agent(ChatRequest(question="Compare this workload"), client, FixtureTools())
|
| 626 |
+
|
| 627 |
+
|
| 628 |
+
@pytest.mark.parametrize("api_ids", [[], ["invented/model"]])
|
| 629 |
+
def test_normalized_selection_still_rejects_empty_or_unknown_choices_when_candidates_exist(api_ids):
|
| 630 |
+
selection = {**SELECTION, "api_model_ids": api_ids}
|
| 631 |
+
client, _, _ = make_client([block(), json.dumps({"selection_schema": selection})])
|
| 632 |
+
with pytest.raises(AgentError, match="not grounded"):
|
| 633 |
+
run_agent(ChatRequest(question="Compare this workload"), client, FixtureTools())
|
| 634 |
+
|
| 635 |
+
|
| 636 |
+
def test_genuine_no_match_keeps_empty_lists_without_inventing_options():
|
| 637 |
+
class NoMatches:
|
| 638 |
+
def execute(self, name, arguments):
|
| 639 |
+
return {"options": [], "source_url": "https://openrouter.ai/api/v1/models"}
|
| 640 |
+
|
| 641 |
+
selection = {**SELECTION, "api_model_ids": [], "compute_ids": []}
|
| 642 |
+
client, _, tokenizer = make_client([block(), json.dumps({"selection_schema": selection})])
|
| 643 |
+
result = run_agent(ChatRequest(question="Compare this workload"), client, NoMatches())
|
| 644 |
+
assert result["selection"] == selection
|
| 645 |
+
assert "No options matched" in result["answer"]
|
| 646 |
+
reference = checkpoint_reference(tokenizer)
|
| 647 |
+
assert reference["allowed_api_model_ids"] == reference["allowed_compute_ids"] == []
|
| 648 |
+
|
| 649 |
+
|
| 650 |
+
def test_bare_valid_selection_is_not_rewritten_or_marked_as_normalized():
|
| 651 |
+
client, _, _ = make_client([block(), json.dumps(SELECTION)])
|
| 652 |
+
result = run_agent(ChatRequest(question="Compare this workload"), client, FixtureTools())
|
| 653 |
+
assert result["selection"] == SELECTION
|
| 654 |
+
assert "format_normalization" not in result["usage"][-1]
|
tests/test_local_planner.py
CHANGED
|
@@ -226,10 +226,11 @@ def test_same_typed_agent_and_cached_catalog_are_used(monkeypatch):
|
|
| 226 |
assert result["model_revision"] == MODEL_REVISION
|
| 227 |
|
| 228 |
|
| 229 |
-
|
|
|
|
| 230 |
monkeypatch.setattr(ui, "run_qwen_on_gpu", lambda *a: pytest.fail("Unexpected GPU request"))
|
| 231 |
status, answer, record = ui.run_public_qwen(
|
| 232 |
-
"Compare options",
|
| 233 |
)
|
| 234 |
assert "Approve" in status
|
| 235 |
assert answer == ""
|
|
@@ -271,6 +272,192 @@ def test_failure_log_has_only_type_and_reviewed_code(caplog, message, reason):
|
|
| 271 |
assert message not in caplog.text
|
| 272 |
|
| 273 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 274 |
def test_disabled_local_planner_has_no_model_endpoint(monkeypatch):
|
| 275 |
monkeypatch.setattr(ui, "planner_chat_status", lambda: {"enabled": False, "protected": False})
|
| 276 |
app = ui.build_app()
|
|
|
|
| 226 |
assert result["model_revision"] == MODEL_REVISION
|
| 227 |
|
| 228 |
|
| 229 |
+
@pytest.mark.parametrize("consent", [False, None, 0, 1, "true"])
|
| 230 |
+
def test_missing_consent_never_requests_gpu(monkeypatch, consent):
|
| 231 |
monkeypatch.setattr(ui, "run_qwen_on_gpu", lambda *a: pytest.fail("Unexpected GPU request"))
|
| 232 |
status, answer, record = ui.run_public_qwen(
|
| 233 |
+
"Compare options", consent, 1000, 2000, 500, 160, 8, 16, 4, False
|
| 234 |
)
|
| 235 |
assert "Approve" in status
|
| 236 |
assert answer == ""
|
|
|
|
| 272 |
assert message not in caplog.text
|
| 273 |
|
| 274 |
|
| 275 |
+
def public_inputs(**updates):
|
| 276 |
+
return {
|
| 277 |
+
"question": "Compare options",
|
| 278 |
+
"consent": True,
|
| 279 |
+
"requests": 1000,
|
| 280 |
+
"incoming": 2000,
|
| 281 |
+
"outgoing": 500,
|
| 282 |
+
"hours": 160,
|
| 283 |
+
"parameters": 8,
|
| 284 |
+
"bits": 16,
|
| 285 |
+
"kv_cache": 4,
|
| 286 |
+
"expose_port": False,
|
| 287 |
+
**updates,
|
| 288 |
+
}
|
| 289 |
+
|
| 290 |
+
|
| 291 |
+
@pytest.fixture
|
| 292 |
+
def gpu_body():
|
| 293 |
+
# Spaces returns the undecorated function when ZeroGPU is disabled. Never run
|
| 294 |
+
# these fake-model tests against an allocation-capable runtime.
|
| 295 |
+
assert ui.zero_gpu_enabled() is False
|
| 296 |
+
return ui.run_qwen_on_gpu
|
| 297 |
+
|
| 298 |
+
|
| 299 |
+
@pytest.mark.parametrize(
|
| 300 |
+
"updates",
|
| 301 |
+
[
|
| 302 |
+
{"question": ""},
|
| 303 |
+
{"question": "private question " * 200},
|
| 304 |
+
{"requests": -1},
|
| 305 |
+
{"requests": float("nan")},
|
| 306 |
+
{"incoming": 0},
|
| 307 |
+
{"hours": 745},
|
| 308 |
+
{"parameters": float("inf")},
|
| 309 |
+
{"bits": 7},
|
| 310 |
+
{"kv_cache": -1},
|
| 311 |
+
],
|
| 312 |
+
)
|
| 313 |
+
def test_invalid_public_controls_never_request_gpu(monkeypatch, updates):
|
| 314 |
+
enable_reviewed_runtime(monkeypatch)
|
| 315 |
+
monkeypatch.setattr(local_planner, "_client", object())
|
| 316 |
+
monkeypatch.setattr(ui, "run_qwen_on_gpu", lambda *a: pytest.fail("Unexpected GPU request"))
|
| 317 |
+
status, answer, record = ui.run_public_qwen(**public_inputs(**updates))
|
| 318 |
+
assert "Check the question and workload controls" in status
|
| 319 |
+
assert "private question" not in status
|
| 320 |
+
assert answer == ""
|
| 321 |
+
assert record is None
|
| 322 |
+
|
| 323 |
+
|
| 324 |
+
def test_unready_public_runtime_never_requests_gpu(monkeypatch):
|
| 325 |
+
monkeypatch.setattr(ui, "run_qwen_on_gpu", lambda *a: pytest.fail("Unexpected GPU request"))
|
| 326 |
+
status, answer, record = ui.run_public_qwen(**public_inputs())
|
| 327 |
+
assert "local Qwen runtime is not ready" in status
|
| 328 |
+
assert answer == ""
|
| 329 |
+
assert record is None
|
| 330 |
+
|
| 331 |
+
|
| 332 |
+
@pytest.mark.parametrize("message,reason", list(local_planner.FAILURE_CODES.items()))
|
| 333 |
+
def test_gpu_helper_returns_safe_envelope_before_exception_can_escape(
|
| 334 |
+
monkeypatch, gpu_body, caplog, capsys, message, reason
|
| 335 |
+
):
|
| 336 |
+
import json
|
| 337 |
+
|
| 338 |
+
from planner.agent import AgentError
|
| 339 |
+
|
| 340 |
+
payload = {"question": "PRIVATE_INPUT_SENTINEL"}
|
| 341 |
+
|
| 342 |
+
def fail(received, consent):
|
| 343 |
+
assert received is payload
|
| 344 |
+
assert consent is True
|
| 345 |
+
raise AgentError(message) from ValueError("PRIVATE_CAUSE_SENTINEL")
|
| 346 |
+
|
| 347 |
+
monkeypatch.setattr(ui, "run_local_comparison", fail)
|
| 348 |
+
record = gpu_body(payload)
|
| 349 |
+
assert record == {"status": "error", "reason": reason}
|
| 350 |
+
assert local_planner.LocalPlannerFailure.model_validate(record).reason == reason
|
| 351 |
+
captured = capsys.readouterr()
|
| 352 |
+
exposed = json.dumps(record) + caplog.text + captured.out + captured.err
|
| 353 |
+
assert message not in exposed
|
| 354 |
+
assert "PRIVATE_" not in exposed
|
| 355 |
+
assert "Traceback" not in exposed
|
| 356 |
+
|
| 357 |
+
|
| 358 |
+
def test_gpu_failure_never_formats_exception_arguments_or_causes(monkeypatch, gpu_body, caplog):
|
| 359 |
+
import json
|
| 360 |
+
|
| 361 |
+
class UnprintableError(RuntimeError):
|
| 362 |
+
def __str__(self):
|
| 363 |
+
pytest.fail("Exception formatting must not run")
|
| 364 |
+
|
| 365 |
+
def __repr__(self):
|
| 366 |
+
pytest.fail("Exception repr must not run")
|
| 367 |
+
|
| 368 |
+
def fail(*args, **kwargs):
|
| 369 |
+
raise UnprintableError({"private": "PRIVATE_BODY_SENTINEL"}) from UnprintableError(
|
| 370 |
+
"PRIVATE_CAUSE_SENTINEL"
|
| 371 |
+
)
|
| 372 |
+
|
| 373 |
+
monkeypatch.setattr(ui, "run_local_comparison", fail)
|
| 374 |
+
record = gpu_body({})
|
| 375 |
+
assert record == {"status": "error", "reason": "unclassified_runtime_error"}
|
| 376 |
+
assert "PRIVATE_" not in json.dumps(record) + caplog.text
|
| 377 |
+
assert "UnprintableError" not in caplog.text
|
| 378 |
+
|
| 379 |
+
|
| 380 |
+
@pytest.mark.parametrize("reason", list(local_planner.FAILURE_MESSAGES))
|
| 381 |
+
def test_public_callback_handles_every_allowed_failure_without_a_result(monkeypatch, reason):
|
| 382 |
+
from planner.contracts import ChatRequest
|
| 383 |
+
|
| 384 |
+
monkeypatch.setattr(ui, "validate_local_request", lambda payload, _: ChatRequest(**payload))
|
| 385 |
+
monkeypatch.setattr(ui, "run_qwen_on_gpu", lambda _: {"status": "error", "reason": reason})
|
| 386 |
+
status, answer, record = ui.run_public_qwen(**public_inputs())
|
| 387 |
+
assert "No comparison was accepted" in status
|
| 388 |
+
assert local_planner.FAILURE_MESSAGES[reason] in status
|
| 389 |
+
assert answer == ""
|
| 390 |
+
assert record is None
|
| 391 |
+
|
| 392 |
+
|
| 393 |
+
@pytest.mark.parametrize(
|
| 394 |
+
"returned",
|
| 395 |
+
[
|
| 396 |
+
None,
|
| 397 |
+
[],
|
| 398 |
+
{"status": "error"},
|
| 399 |
+
{"reason": "turn_limit"},
|
| 400 |
+
{"status": "success", "reason": "turn_limit"},
|
| 401 |
+
{"status": "error", "reason": "PRIVATE_UNREVIEWED_REASON"},
|
| 402 |
+
{"status": "error", "reason": ["turn_limit"]},
|
| 403 |
+
{"status": "error", "reason": "turn_limit", "answer": "PRIVATE_OLD_ANSWER"},
|
| 404 |
+
{"error": "PRIVATE_RAW_FAILURE", "answer": "PRIVATE_OLD_ANSWER"},
|
| 405 |
+
{"answer": None},
|
| 406 |
+
{"answer": {"private": "PRIVATE_UNVALIDATED_ANSWER"}},
|
| 407 |
+
{"answer": " "},
|
| 408 |
+
],
|
| 409 |
+
)
|
| 410 |
+
def test_malformed_gpu_results_are_not_mistaken_for_success(monkeypatch, returned):
|
| 411 |
+
from planner.contracts import ChatRequest
|
| 412 |
+
|
| 413 |
+
monkeypatch.setattr(ui, "validate_local_request", lambda payload, _: ChatRequest(**payload))
|
| 414 |
+
monkeypatch.setattr(ui, "run_qwen_on_gpu", lambda _: returned)
|
| 415 |
+
status, answer, record = ui.run_public_qwen(**public_inputs())
|
| 416 |
+
assert "local runtime returned an invalid result" in status
|
| 417 |
+
assert "PRIVATE_" not in status
|
| 418 |
+
assert answer == ""
|
| 419 |
+
assert record is None
|
| 420 |
+
|
| 421 |
+
|
| 422 |
+
def test_gpu_success_is_returned_unchanged_and_later_failure_clears_it(monkeypatch, gpu_body):
|
| 423 |
+
from planner.contracts import ChatRequest
|
| 424 |
+
|
| 425 |
+
original = {
|
| 426 |
+
"answer": "Recorded source-backed comparison",
|
| 427 |
+
"selection": {"api_model_ids": ["fixture/model"]},
|
| 428 |
+
"tool_trace": [{"tool": "recorded test tool"}],
|
| 429 |
+
"usage": [{"total_tokens": 3}],
|
| 430 |
+
"backend": "local_transformers_zerogpu",
|
| 431 |
+
"provider_calls": 0,
|
| 432 |
+
}
|
| 433 |
+
monkeypatch.setattr(ui, "run_local_comparison", lambda *a, **k: original)
|
| 434 |
+
assert gpu_body({}) is original
|
| 435 |
+
monkeypatch.setattr(ui, "validate_local_request", lambda payload, _: ChatRequest(**payload))
|
| 436 |
+
monkeypatch.setattr(ui, "run_qwen_on_gpu", gpu_body)
|
| 437 |
+
status, answer, record = ui.run_public_qwen(**public_inputs())
|
| 438 |
+
assert "comparison completed" in status
|
| 439 |
+
assert answer is original["answer"]
|
| 440 |
+
assert record is original
|
| 441 |
+
|
| 442 |
+
def fail(*args, **kwargs):
|
| 443 |
+
raise RuntimeError("PRIVATE_LATER_FAILURE")
|
| 444 |
+
|
| 445 |
+
monkeypatch.setattr(ui, "run_local_comparison", fail)
|
| 446 |
+
status, answer, record = ui.run_public_qwen(**public_inputs())
|
| 447 |
+
assert "No comparison was accepted" in status
|
| 448 |
+
assert "PRIVATE_" not in status
|
| 449 |
+
assert answer == ""
|
| 450 |
+
assert record is None
|
| 451 |
+
|
| 452 |
+
|
| 453 |
+
def test_failure_codes_and_status_messages_have_the_same_closed_set():
|
| 454 |
+
from typing import get_args
|
| 455 |
+
|
| 456 |
+
reasons = set(get_args(local_planner.FailureReason))
|
| 457 |
+
assert set(local_planner.FAILURE_MESSAGES) == reasons
|
| 458 |
+
assert set(local_planner.FAILURE_CODES.values()) <= reasons
|
| 459 |
+
|
| 460 |
+
|
| 461 |
def test_disabled_local_planner_has_no_model_endpoint(monkeypatch):
|
| 462 |
monkeypatch.setattr(ui, "planner_chat_status", lambda: {"enabled": False, "protected": False})
|
| 463 |
app = ui.build_app()
|