refactor: use a single agent model path
This commit is contained in:
@@ -1,15 +1,8 @@
|
|||||||
# K1412 Agent
|
# K1412 Agent
|
||||||
|
|
||||||
K1412 Agent is a multi-user web Agent built around two deliberately different
|
K1412 Agent is a multi-user web coding Agent. Every user request enters the
|
||||||
experiences:
|
same independently evolvable Agent loop; its scheduler, context policy, memory,
|
||||||
|
tools, evidence rules, and child Agents are owned by this repository.
|
||||||
- **Chat** is fast, conversational, and uses Open WebUI's native tool loop.
|
|
||||||
- **Work** is a long-running coding Agent whose loop, scheduler, context,
|
|
||||||
memory, tools, and sub-agents are owned by this repository.
|
|
||||||
|
|
||||||
A conversation may be upgraded from Chat to Work. The upgrade is intentionally
|
|
||||||
one-way so that two independent Agent loops never compete for the same
|
|
||||||
conversation state.
|
|
||||||
|
|
||||||
The production instance is available at <https://agent.k1412.top>.
|
The production instance is available at <https://agent.k1412.top>.
|
||||||
|
|
||||||
@@ -22,7 +15,7 @@ browser
|
|||||||
Open WebUI (auth, RBAC, chat history, UI)
|
Open WebUI (auth, RBAC, chat history, UI)
|
||||||
|
|
|
|
||||||
v
|
v
|
||||||
Agent Runtime (model gateway + Work loop)
|
Agent Runtime (model gateway + Agent loop)
|
||||||
|
|
|
|
||||||
v
|
v
|
||||||
Workspace Gateway (identity, policy, audit)
|
Workspace Gateway (identity, policy, audit)
|
||||||
@@ -31,19 +24,24 @@ Workspace Gateway (identity, policy, audit)
|
|||||||
one Docker workspace per Open WebUI user on a dedicated execution host
|
one Docker workspace per Open WebUI user on a dedicated execution host
|
||||||
```
|
```
|
||||||
|
|
||||||
Open WebUI is pinned and lightly patched. Its model picker is replaced by two
|
Open WebUI is pinned and lightly patched. Its picker exposes only four model
|
||||||
product-level choices: `Chat / Work` and `轻度 / 中 / 高 / 极高`. Provider URLs,
|
names and a separate read-only thinking status. Provider URLs, provider model
|
||||||
provider model IDs, API keys, tool server settings, system prompts, and runtime
|
IDs, API keys, tool server settings, system prompts, and runtime parameters
|
||||||
parameters remain server-side.
|
remain server-side.
|
||||||
|
|
||||||
The inference mapping is fixed:
|
Model identity and thinking control are intentionally separate:
|
||||||
|
|
||||||
| Strength | Provider model |
|
| UI model | Provider model | Thinking | Reasoning effort |
|
||||||
| --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| 轻度 | `ChatGPT-5.6:Luna` |
|
| Luna | `ChatGPT-5.6:Luna` | On, no effort levels | — |
|
||||||
| 中 | `ChatGPT-5.6:Terra` |
|
| Terra | `ChatGPT-5.6:Terra` | On, no effort levels | — |
|
||||||
| 高 | `ChatGPT-5.6:Sol` |
|
| Sol | `ChatGPT-5.6:Sol` | On, no effort levels | — |
|
||||||
| 极高 | `deepseek-v4-pro`(Thinking,`reasoning_effort=max`) |
|
| DeepSeek V4 Pro | `deepseek-v4-pro` | On | Extreme (`max`) |
|
||||||
|
|
||||||
|
The three Ollama models advertise `thinking`, but they are Qwen-family models
|
||||||
|
with a boolean thinking switch rather than GPT-OSS-style low/medium/high
|
||||||
|
effort levels. Luna, Terra, and Sol are different models, not three reasoning
|
||||||
|
settings for one model.
|
||||||
|
|
||||||
## Local development
|
## Local development
|
||||||
|
|
||||||
@@ -64,9 +62,8 @@ The inference mapping is fixed:
|
|||||||
4. Open <http://localhost:3000>. New users register as `pending` and require
|
4. Open <http://localhost:3000>. New users register as `pending` and require
|
||||||
approval by the bootstrap administrator.
|
approval by the bootstrap administrator.
|
||||||
|
|
||||||
The pasted provider credential from the planning conversation is intentionally
|
Provider credentials are intentionally not stored here. Keep them only in the
|
||||||
not stored here. Rotate it and place the replacement only in the protected
|
protected deployment `.env`.
|
||||||
deployment `.env`.
|
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
@@ -83,14 +80,14 @@ test, with:
|
|||||||
./scripts/verify.sh
|
./scripts/verify.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
Run a live Work evaluation in a unique, disposable, network-disabled Docker
|
Run a live Agent evaluation in a unique, disposable, network-disabled Docker
|
||||||
workspace with:
|
workspace with:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
set -a
|
set -a
|
||||||
source /path/to/protected/deepseek.env
|
source /path/to/protected/deepseek.env
|
||||||
set +a
|
set +a
|
||||||
.venv/bin/python scripts/eval-live-work.py --model work-extreme
|
.venv/bin/python scripts/eval-live-work.py --model deepseek-v4-pro
|
||||||
```
|
```
|
||||||
|
|
||||||
The evaluator reports latency, token usage, tool counts, completion evidence,
|
The evaluator reports latency, token usage, tool counts, completion evidence,
|
||||||
@@ -98,8 +95,8 @@ and the generated file listing. It removes only the uniquely named evaluation
|
|||||||
container, volume, and network unless `--keep-workspace` is supplied.
|
container, volume, and network unless `--keep-workspace` is supplied.
|
||||||
|
|
||||||
Run the disposable six-service integration stack with a deterministic fake
|
Run the disposable six-service integration stack with a deterministic fake
|
||||||
model provider and exercise registration approval, both Agent loops, workspace
|
model provider and exercise registration approval, the Agent loop, workspace
|
||||||
isolation, and the one-way mode upgrade with:
|
isolation, and file delivery with:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./scripts/verify-e2e.sh
|
./scripts/verify-e2e.sh
|
||||||
@@ -116,7 +113,7 @@ High/Critical image vulnerabilities, with:
|
|||||||
./scripts/audit-images.sh
|
./scripts/audit-images.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
Generated files are available from the **工作区文件** button beside the mode
|
Generated files are available from the **工作区文件** button beside the model
|
||||||
selector. Users can browse directories, download one file, or download the
|
selector. Users can browse directories, download one file, or download the
|
||||||
current directory as a `.tar.gz` archive. Browser requests are authenticated by
|
current directory as a `.tar.gz` archive. Browser requests are authenticated by
|
||||||
Open WebUI and re-signed for the Workspace Gateway; no internal key is exposed.
|
Open WebUI and re-signed for the Workspace Gateway; no internal key is exposed.
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ import httpx
|
|||||||
|
|
||||||
from agent_platform.models import MODEL_SPECS
|
from agent_platform.models import MODEL_SPECS
|
||||||
|
|
||||||
|
LEGACY_MODEL_IDS = {
|
||||||
|
f"{mode}-{strength}" for mode in ("chat", "work") for strength in ("light", "medium", "high", "extreme")
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _required(name: str) -> str:
|
def _required(name: str) -> str:
|
||||||
value = os.getenv(name, "").strip()
|
value = os.getenv(name, "").strip()
|
||||||
@@ -33,27 +37,24 @@ def _model_payload() -> list[dict[str, Any]]:
|
|||||||
public_read = [{"principal_type": "user", "principal_id": "*", "permission": "read"}]
|
public_read = [{"principal_type": "user", "principal_id": "*", "permission": "read"}]
|
||||||
items = []
|
items = []
|
||||||
for spec in MODEL_SPECS.values():
|
for spec in MODEL_SPECS.values():
|
||||||
is_chat = spec.mode == "chat"
|
|
||||||
items.append(
|
items.append(
|
||||||
{
|
{
|
||||||
"id": spec.public_id,
|
"id": spec.public_id,
|
||||||
"base_model_id": None,
|
"base_model_id": None,
|
||||||
"name": spec.display_name,
|
"name": spec.display_name,
|
||||||
"params": {"function_calling": "native"} if is_chat else {},
|
"params": {},
|
||||||
"meta": {
|
"meta": {
|
||||||
"description": (
|
"description": (f"K1412 Agent;思考:{spec.thinking_label}。"),
|
||||||
"快速问答,使用原生工具循环。" if is_chat else "长链路工作,使用 K1412 Work Agent。"
|
"toolIds": [],
|
||||||
),
|
|
||||||
"toolIds": ["server:workspace"] if is_chat else [],
|
|
||||||
"capabilities": {
|
"capabilities": {
|
||||||
"tool_calling": is_chat,
|
"tool_calling": False,
|
||||||
"builtin_tools": False,
|
"builtin_tools": False,
|
||||||
"file_context": False,
|
"file_context": False,
|
||||||
"citations": False,
|
"citations": False,
|
||||||
"vision": False,
|
"vision": False,
|
||||||
"file_upload": False,
|
"file_upload": False,
|
||||||
},
|
},
|
||||||
"tags": [{"name": "Chat" if is_chat else "Work"}],
|
"tags": [{"name": "Agent"}],
|
||||||
},
|
},
|
||||||
"access_grants": public_read,
|
"access_grants": public_read,
|
||||||
"is_active": True,
|
"is_active": True,
|
||||||
@@ -151,6 +152,22 @@ async def bootstrap() -> None:
|
|||||||
)
|
)
|
||||||
tool_server_response.raise_for_status()
|
tool_server_response.raise_for_status()
|
||||||
|
|
||||||
|
for model_id in sorted(LEGACY_MODEL_IDS):
|
||||||
|
existing = await client.get(
|
||||||
|
f"{base_url}/api/v1/models/model",
|
||||||
|
headers=headers,
|
||||||
|
params={"id": model_id},
|
||||||
|
)
|
||||||
|
if existing.status_code == 404:
|
||||||
|
continue
|
||||||
|
existing.raise_for_status()
|
||||||
|
delete_response = await client.post(
|
||||||
|
f"{base_url}/api/v1/models/model/delete",
|
||||||
|
headers=headers,
|
||||||
|
json={"id": model_id},
|
||||||
|
)
|
||||||
|
delete_response.raise_for_status()
|
||||||
|
|
||||||
model_response = await client.post(
|
model_response = await client.post(
|
||||||
f"{base_url}/api/v1/models/import",
|
f"{base_url}/api/v1/models/import",
|
||||||
headers=headers,
|
headers=headers,
|
||||||
@@ -158,7 +175,7 @@ async def bootstrap() -> None:
|
|||||||
)
|
)
|
||||||
model_response.raise_for_status()
|
model_response.raise_for_status()
|
||||||
|
|
||||||
print("Open WebUI bootstrap complete: auth policy, workspace tools, and eight fixed Agent entries are ready.")
|
print("Open WebUI bootstrap complete: auth policy, workspace tools, and four Agent models are ready.")
|
||||||
|
|
||||||
|
|
||||||
def run() -> None:
|
def run() -> None:
|
||||||
|
|||||||
+59
-35
@@ -3,8 +3,6 @@ from __future__ import annotations
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
|
||||||
Mode = Literal["chat", "work"]
|
|
||||||
Strength = Literal["light", "medium", "high", "extreme"]
|
|
||||||
Provider = Literal["k1412", "deepseek"]
|
Provider = Literal["k1412", "deepseek"]
|
||||||
|
|
||||||
|
|
||||||
@@ -12,49 +10,70 @@ Provider = Literal["k1412", "deepseek"]
|
|||||||
class ModelSpec:
|
class ModelSpec:
|
||||||
public_id: str
|
public_id: str
|
||||||
display_name: str
|
display_name: str
|
||||||
mode: Mode
|
|
||||||
strength: Strength
|
|
||||||
provider: Provider
|
provider: Provider
|
||||||
provider_model: str
|
provider_model: str
|
||||||
thinking_enabled: bool
|
thinking_enabled: bool
|
||||||
|
thinking_label: str
|
||||||
|
thinking_adjustable: bool
|
||||||
reasoning_effort: str | None
|
reasoning_effort: str | None
|
||||||
max_output_tokens: int
|
max_output_tokens: int
|
||||||
max_iterations: int
|
max_iterations: int
|
||||||
context_char_budget: int
|
context_char_budget: int
|
||||||
|
|
||||||
|
|
||||||
_TIERS = {
|
|
||||||
"light": ("轻度", "k1412", "ChatGPT-5.6:Luna", False, None, 4_096, 8, 120_000),
|
|
||||||
"medium": ("中", "k1412", "ChatGPT-5.6:Terra", False, None, 4_096, 16, 240_000),
|
|
||||||
"high": ("高", "k1412", "ChatGPT-5.6:Sol", False, None, 4_096, 24, 400_000),
|
|
||||||
"extreme": ("极高", "deepseek", "deepseek-v4-pro", True, "max", 16_384, 32, 800_000),
|
|
||||||
}
|
|
||||||
|
|
||||||
MODEL_SPECS: dict[str, ModelSpec] = {
|
MODEL_SPECS: dict[str, ModelSpec] = {
|
||||||
f"{mode}-{strength}": ModelSpec(
|
"luna": ModelSpec(
|
||||||
public_id=f"{mode}-{strength}",
|
public_id="luna",
|
||||||
display_name=f"{'Chat' if mode == 'chat' else 'Work'} · {label}",
|
display_name="Luna",
|
||||||
mode=mode,
|
provider="k1412",
|
||||||
strength=strength,
|
provider_model="ChatGPT-5.6:Luna",
|
||||||
provider=provider,
|
thinking_enabled=True,
|
||||||
provider_model=provider_model,
|
thinking_label="开启(不分档)",
|
||||||
thinking_enabled=thinking_enabled,
|
thinking_adjustable=False,
|
||||||
reasoning_effort=reasoning_effort,
|
reasoning_effort=None,
|
||||||
max_output_tokens=max_output_tokens,
|
max_output_tokens=4_096,
|
||||||
max_iterations=max_iterations,
|
max_iterations=8,
|
||||||
context_char_budget=context_budget,
|
context_char_budget=120_000,
|
||||||
)
|
),
|
||||||
for mode in ("chat", "work")
|
"terra": ModelSpec(
|
||||||
for strength, (
|
public_id="terra",
|
||||||
label,
|
display_name="Terra",
|
||||||
provider,
|
provider="k1412",
|
||||||
provider_model,
|
provider_model="ChatGPT-5.6:Terra",
|
||||||
thinking_enabled,
|
thinking_enabled=True,
|
||||||
reasoning_effort,
|
thinking_label="开启(不分档)",
|
||||||
max_output_tokens,
|
thinking_adjustable=False,
|
||||||
max_iterations,
|
reasoning_effort=None,
|
||||||
context_budget,
|
max_output_tokens=4_096,
|
||||||
) in _TIERS.items()
|
max_iterations=16,
|
||||||
|
context_char_budget=240_000,
|
||||||
|
),
|
||||||
|
"sol": ModelSpec(
|
||||||
|
public_id="sol",
|
||||||
|
display_name="Sol",
|
||||||
|
provider="k1412",
|
||||||
|
provider_model="ChatGPT-5.6:Sol",
|
||||||
|
thinking_enabled=True,
|
||||||
|
thinking_label="开启(不分档)",
|
||||||
|
thinking_adjustable=False,
|
||||||
|
reasoning_effort=None,
|
||||||
|
max_output_tokens=4_096,
|
||||||
|
max_iterations=24,
|
||||||
|
context_char_budget=400_000,
|
||||||
|
),
|
||||||
|
"deepseek-v4-pro": ModelSpec(
|
||||||
|
public_id="deepseek-v4-pro",
|
||||||
|
display_name="DeepSeek V4 Pro",
|
||||||
|
provider="deepseek",
|
||||||
|
provider_model="deepseek-v4-pro",
|
||||||
|
thinking_enabled=True,
|
||||||
|
thinking_label="极高",
|
||||||
|
thinking_adjustable=False,
|
||||||
|
reasoning_effort="max",
|
||||||
|
max_output_tokens=16_384,
|
||||||
|
max_iterations=32,
|
||||||
|
context_char_budget=800_000,
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -74,6 +93,11 @@ def openai_model_list() -> dict:
|
|||||||
"object": "model",
|
"object": "model",
|
||||||
"owned_by": "k1412-agent",
|
"owned_by": "k1412-agent",
|
||||||
"name": spec.display_name,
|
"name": spec.display_name,
|
||||||
|
"k1412": {
|
||||||
|
"thinking_enabled": spec.thinking_enabled,
|
||||||
|
"thinking_label": spec.thinking_label,
|
||||||
|
"thinking_adjustable": spec.thinking_adjustable,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
for spec in MODEL_SPECS.values()
|
for spec in MODEL_SPECS.values()
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
"""Model gateway and independently evolvable Work Agent runtime."""
|
"""Model gateway and independently evolvable K1412 Agent runtime."""
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ from typing import Annotated, Any
|
|||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
import uvicorn
|
import uvicorn
|
||||||
from fastapi import FastAPI, Header, HTTPException, status
|
from fastapi import FastAPI, Header, HTTPException
|
||||||
from fastapi.responses import JSONResponse, StreamingResponse
|
from fastapi.responses import JSONResponse, StreamingResponse
|
||||||
|
|
||||||
from agent_platform.auth import UserIdentity, decode_openwebui_identity, verify_service_bearer
|
from agent_platform.auth import UserIdentity, decode_openwebui_identity, verify_service_bearer
|
||||||
@@ -198,16 +198,6 @@ def create_app(
|
|||||||
return await forward_direct()
|
return await forward_direct()
|
||||||
|
|
||||||
stable_chat_id = (chat_id or message_id or f"ephemeral-{uuid.uuid4().hex}").strip()
|
stable_chat_id = (chat_id or message_id or f"ephemeral-{uuid.uuid4().hex}").strip()
|
||||||
selected_mode = await app.state.store.select_mode(identity.user_id, chat_id or "", spec.mode)
|
|
||||||
if selected_mode == "work" and spec.mode == "chat":
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_409_CONFLICT,
|
|
||||||
detail="This conversation has been upgraded to Work and cannot return to Chat.",
|
|
||||||
)
|
|
||||||
|
|
||||||
if spec.mode == "chat":
|
|
||||||
return await forward_direct()
|
|
||||||
|
|
||||||
messages = [message.model_dump(exclude_none=True) for message in body.messages]
|
messages = [message.model_dump(exclude_none=True) for message in body.messages]
|
||||||
if not body.stream:
|
if not body.stream:
|
||||||
|
|
||||||
@@ -224,7 +214,7 @@ def create_app(
|
|||||||
)
|
)
|
||||||
return _completion(body.model, answer)
|
return _completion(body.model, answer)
|
||||||
|
|
||||||
async def work_stream() -> AsyncIterator[bytes]:
|
async def agent_stream() -> AsyncIterator[bytes]:
|
||||||
queue: asyncio.Queue[tuple[str, Any]] = asyncio.Queue()
|
queue: asyncio.Queue[tuple[str, Any]] = asyncio.Queue()
|
||||||
|
|
||||||
async def publish(event_type: str, payload: dict[str, Any]) -> None:
|
async def publish(event_type: str, payload: dict[str, Any]) -> None:
|
||||||
@@ -255,7 +245,7 @@ def create_app(
|
|||||||
if kind == "done":
|
if kind == "done":
|
||||||
break
|
break
|
||||||
if kind == "error":
|
if kind == "error":
|
||||||
yield _sse_chunk(body.model, f"\n\nWork 运行失败:{value}")
|
yield _sse_chunk(body.model, f"\n\nAgent 运行失败:{value}")
|
||||||
continue
|
continue
|
||||||
if kind == "answer":
|
if kind == "answer":
|
||||||
text = str(value)
|
text = str(value)
|
||||||
@@ -271,7 +261,7 @@ def create_app(
|
|||||||
await asyncio.gather(task, return_exceptions=True)
|
await asyncio.gather(task, return_exceptions=True)
|
||||||
|
|
||||||
return StreamingResponse(
|
return StreamingResponse(
|
||||||
work_stream(),
|
agent_stream(),
|
||||||
media_type="text/event-stream",
|
media_type="text/event-stream",
|
||||||
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ from agent_platform.store import RuntimeStore
|
|||||||
EventCallback = Callable[[str, dict[str, Any]], Awaitable[None]]
|
EventCallback = Callable[[str, dict[str, Any]], Awaitable[None]]
|
||||||
|
|
||||||
WORK_SYSTEM_PROMPT = """\
|
WORK_SYSTEM_PROMPT = """\
|
||||||
You are Work, an autonomous coding Agent operating in one isolated user workspace at /workspace.
|
You are K1412 Agent, an autonomous coding Agent operating in one isolated user workspace at /workspace.
|
||||||
|
|
||||||
Own the outcome: inspect the workspace, form a plan for non-trivial work, make scoped changes, run
|
Own the outcome: inspect the workspace, form a plan for non-trivial work, make scoped changes, run
|
||||||
relevant verification, and report concrete results. Use tools instead of inventing file contents or
|
relevant verification, and report concrete results. Use tools instead of inventing file contents or
|
||||||
@@ -514,8 +514,8 @@ class AgentLoop:
|
|||||||
await recorder.emit(
|
await recorder.emit(
|
||||||
"run.created",
|
"run.created",
|
||||||
{
|
{
|
||||||
"model_tier": spec.strength,
|
"model_id": spec.public_id,
|
||||||
"strategy_version": "work-loop-v2",
|
"strategy_version": "agent-loop-v3",
|
||||||
"scheduler_version": "safe-parallel-v1",
|
"scheduler_version": "safe-parallel-v1",
|
||||||
"context_policy": "recent-visible-v1",
|
"context_policy": "recent-visible-v1",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -200,14 +200,14 @@ TOOL_METADATA: dict[str, ToolMetadata] = {
|
|||||||
),
|
),
|
||||||
"remember": ToolMetadata(
|
"remember": ToolMetadata(
|
||||||
"remember",
|
"remember",
|
||||||
"Store a durable user preference or fact that will help future Work tasks.",
|
"Store a durable user preference or fact that will help future Agent tasks.",
|
||||||
object_schema({"content": {"type": "string"}}, ["content"]),
|
object_schema({"content": {"type": "string"}}, ["content"]),
|
||||||
False,
|
False,
|
||||||
False,
|
False,
|
||||||
),
|
),
|
||||||
"recall_memory": ToolMetadata(
|
"recall_memory": ToolMetadata(
|
||||||
"recall_memory",
|
"recall_memory",
|
||||||
"Search durable Work memory for this user.",
|
"Search durable Agent memory for this user.",
|
||||||
object_schema(
|
object_schema(
|
||||||
{
|
{
|
||||||
"query": {"type": "string", "default": ""},
|
"query": {"type": "string", "default": ""},
|
||||||
@@ -219,7 +219,7 @@ TOOL_METADATA: dict[str, ToolMetadata] = {
|
|||||||
),
|
),
|
||||||
"forget_memory": ToolMetadata(
|
"forget_memory": ToolMetadata(
|
||||||
"forget_memory",
|
"forget_memory",
|
||||||
"Delete one durable Work memory by id.",
|
"Delete one durable Agent memory by id.",
|
||||||
object_schema({"memory_id": {"type": "string"}}, ["memory_id"]),
|
object_schema({"memory_id": {"type": "string"}}, ["memory_id"]),
|
||||||
False,
|
False,
|
||||||
False,
|
False,
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import uuid
|
import uuid
|
||||||
from collections.abc import AsyncIterator
|
from collections.abc import AsyncIterator
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
@@ -16,17 +15,6 @@ class Base(DeclarativeBase):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class ConversationMode(Base):
|
|
||||||
__tablename__ = "agent_conversation_modes"
|
|
||||||
__table_args__ = (UniqueConstraint("user_id", "chat_id"),)
|
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
|
||||||
user_id: Mapped[str] = mapped_column(String(128), index=True)
|
|
||||||
chat_id: Mapped[str] = mapped_column(String(256), index=True)
|
|
||||||
mode: Mapped[str] = mapped_column(String(16))
|
|
||||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(UTC))
|
|
||||||
|
|
||||||
|
|
||||||
class RunEvent(Base):
|
class RunEvent(Base):
|
||||||
__tablename__ = "agent_run_events"
|
__tablename__ = "agent_run_events"
|
||||||
|
|
||||||
@@ -64,8 +52,6 @@ class RuntimeStore:
|
|||||||
def __init__(self, url: str) -> None:
|
def __init__(self, url: str) -> None:
|
||||||
self.engine: AsyncEngine = create_async_engine(url, pool_pre_ping=True)
|
self.engine: AsyncEngine = create_async_engine(url, pool_pre_ping=True)
|
||||||
self.sessions = async_sessionmaker(self.engine, expire_on_commit=False)
|
self.sessions = async_sessionmaker(self.engine, expire_on_commit=False)
|
||||||
self._mode_locks: dict[tuple[str, str], asyncio.Lock] = {}
|
|
||||||
self._lock_guard = asyncio.Lock()
|
|
||||||
|
|
||||||
async def initialize(self) -> None:
|
async def initialize(self) -> None:
|
||||||
async with self.engine.begin() as connection:
|
async with self.engine.begin() as connection:
|
||||||
@@ -79,37 +65,6 @@ class RuntimeStore:
|
|||||||
async with self.sessions() as session:
|
async with self.sessions() as session:
|
||||||
yield session
|
yield session
|
||||||
|
|
||||||
async def _mode_lock(self, user_id: str, chat_id: str) -> asyncio.Lock:
|
|
||||||
key = (user_id, chat_id)
|
|
||||||
async with self._lock_guard:
|
|
||||||
return self._mode_locks.setdefault(key, asyncio.Lock())
|
|
||||||
|
|
||||||
async def select_mode(self, user_id: str, chat_id: str, requested: str) -> str:
|
|
||||||
if requested not in {"chat", "work"}:
|
|
||||||
raise ValueError("Invalid conversation mode")
|
|
||||||
if not chat_id:
|
|
||||||
return requested
|
|
||||||
lock = await self._mode_lock(user_id, chat_id)
|
|
||||||
async with lock:
|
|
||||||
async with self.session() as session:
|
|
||||||
row = await session.scalar(
|
|
||||||
select(ConversationMode).where(
|
|
||||||
ConversationMode.user_id == user_id,
|
|
||||||
ConversationMode.chat_id == chat_id,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if row is None:
|
|
||||||
session.add(ConversationMode(user_id=user_id, chat_id=chat_id, mode=requested))
|
|
||||||
await session.commit()
|
|
||||||
return requested
|
|
||||||
if row.mode == "work" and requested == "chat":
|
|
||||||
return "work"
|
|
||||||
if row.mode != requested:
|
|
||||||
row.mode = requested
|
|
||||||
row.updated_at = datetime.now(UTC)
|
|
||||||
await session.commit()
|
|
||||||
return row.mode
|
|
||||||
|
|
||||||
async def append_event(
|
async def append_event(
|
||||||
self,
|
self,
|
||||||
run_id: str,
|
run_id: str,
|
||||||
|
|||||||
+2
-2
@@ -35,8 +35,8 @@ services:
|
|||||||
K1412_GATEWAY_KEY: ${INTERNAL_GATEWAY_KEY:?INTERNAL_GATEWAY_KEY is required}
|
K1412_GATEWAY_KEY: ${INTERNAL_GATEWAY_KEY:?INTERNAL_GATEWAY_KEY is required}
|
||||||
OPENAI_API_BASE_URL: http://runtime:8000/v1
|
OPENAI_API_BASE_URL: http://runtime:8000/v1
|
||||||
OPENAI_API_KEY: ${INTERNAL_PROVIDER_KEY:?INTERNAL_PROVIDER_KEY is required}
|
OPENAI_API_KEY: ${INTERNAL_PROVIDER_KEY:?INTERNAL_PROVIDER_KEY is required}
|
||||||
DEFAULT_MODELS: chat-medium
|
DEFAULT_MODELS: terra
|
||||||
TASK_MODEL: chat-light
|
TASK_MODEL: luna
|
||||||
ENABLE_TITLE_GENERATION: "false"
|
ENABLE_TITLE_GENERATION: "false"
|
||||||
ENABLE_FOLLOW_UP_GENERATION: "false"
|
ENABLE_FOLLOW_UP_GENERATION: "false"
|
||||||
ENABLE_TAGS_GENERATION: "false"
|
ENABLE_TAGS_GENERATION: "false"
|
||||||
|
|||||||
@@ -35,8 +35,8 @@ services:
|
|||||||
K1412_GATEWAY_KEY: ${INTERNAL_GATEWAY_KEY:?INTERNAL_GATEWAY_KEY is required}
|
K1412_GATEWAY_KEY: ${INTERNAL_GATEWAY_KEY:?INTERNAL_GATEWAY_KEY is required}
|
||||||
OPENAI_API_BASE_URL: http://runtime:8000/v1
|
OPENAI_API_BASE_URL: http://runtime:8000/v1
|
||||||
OPENAI_API_KEY: ${INTERNAL_PROVIDER_KEY:?INTERNAL_PROVIDER_KEY is required}
|
OPENAI_API_KEY: ${INTERNAL_PROVIDER_KEY:?INTERNAL_PROVIDER_KEY is required}
|
||||||
DEFAULT_MODELS: chat-medium
|
DEFAULT_MODELS: terra
|
||||||
TASK_MODEL: chat-light
|
TASK_MODEL: luna
|
||||||
ENABLE_TITLE_GENERATION: "false"
|
ENABLE_TITLE_GENERATION: "false"
|
||||||
ENABLE_FOLLOW_UP_GENERATION: "false"
|
ENABLE_FOLLOW_UP_GENERATION: "false"
|
||||||
ENABLE_TAGS_GENERATION: "false"
|
ENABLE_TAGS_GENERATION: "false"
|
||||||
|
|||||||
@@ -7,96 +7,58 @@
|
|||||||
export let showSetDefault = true;
|
export let showSetDefault = true;
|
||||||
let showWorkspace = false;
|
let showWorkspace = false;
|
||||||
|
|
||||||
const strengths = [
|
const modelOptions = [
|
||||||
{ key: 'light', label: '轻度' },
|
{ id: 'luna', label: 'Luna', thinking: '开启 · 不分档' },
|
||||||
{ key: 'medium', label: '中' },
|
{ id: 'terra', label: 'Terra', thinking: '开启 · 不分档' },
|
||||||
{ key: 'high', label: '高' },
|
{ id: 'sol', label: 'Sol', thinking: '开启 · 不分档' },
|
||||||
{ key: 'extreme', label: '极高' }
|
{ id: 'deepseek-v4-pro', label: 'DeepSeek', thinking: '极高' }
|
||||||
];
|
];
|
||||||
|
const allowed = new Set(modelOptions.map((item) => item.id));
|
||||||
|
|
||||||
const allowed = new Set([
|
$: selected = allowed.has(selectedModels?.[0]) ? selectedModels[0] : 'terra';
|
||||||
'chat-light',
|
$: selectedOption = modelOptions.find((item) => item.id === selected) ?? modelOptions[1];
|
||||||
'chat-medium',
|
|
||||||
'chat-high',
|
|
||||||
'chat-extreme',
|
|
||||||
'work-light',
|
|
||||||
'work-medium',
|
|
||||||
'work-high',
|
|
||||||
'work-extreme'
|
|
||||||
]);
|
|
||||||
|
|
||||||
$: selected = allowed.has(selectedModels?.[0]) ? selectedModels[0] : 'chat-medium';
|
|
||||||
$: mode = selected.startsWith('work-') ? 'work' : 'chat';
|
|
||||||
$: strength = selected.split('-')[1] ?? 'medium';
|
|
||||||
$: availableIds = new Set($models.map((model) => model.id));
|
$: availableIds = new Set($models.map((model) => model.id));
|
||||||
|
|
||||||
$: if ($models.length > 0 && !availableIds.has(selectedModels?.[0])) {
|
$: if ($models.length > 0 && !availableIds.has(selectedModels?.[0])) {
|
||||||
const fallback = availableIds.has('chat-medium')
|
const fallback = availableIds.has('terra')
|
||||||
? 'chat-medium'
|
? 'terra'
|
||||||
: [...allowed].find((id) => availableIds.has(id));
|
: modelOptions.find((item) => availableIds.has(item.id))?.id;
|
||||||
if (fallback) selectedModels = [fallback];
|
if (fallback) selectedModels = [fallback];
|
||||||
}
|
}
|
||||||
|
|
||||||
const chooseMode = (nextMode: 'chat' | 'work') => {
|
const chooseModel = (modelId: string) => {
|
||||||
if (disabled || (mode === 'work' && nextMode === 'chat')) return;
|
if (disabled || !availableIds.has(modelId)) return;
|
||||||
const next = `${nextMode}-${strength}`;
|
selectedModels = [modelId];
|
||||||
if (availableIds.has(next)) selectedModels = [next];
|
|
||||||
};
|
|
||||||
|
|
||||||
const chooseStrength = (nextStrength: string) => {
|
|
||||||
if (disabled) return;
|
|
||||||
const next = `${mode}-${nextStrength}`;
|
|
||||||
if (availableIds.has(next)) selectedModels = [next];
|
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex max-w-fit items-center gap-2 select-none">
|
<div class="flex max-w-full items-center gap-2 select-none">
|
||||||
<div
|
<div
|
||||||
class="flex items-center rounded-xl bg-gray-100/90 p-1 text-sm dark:bg-gray-800/90"
|
class="flex items-center overflow-x-auto rounded-xl border border-gray-200/80 p-1 text-xs dark:border-gray-700/80"
|
||||||
aria-label="Agent mode"
|
aria-label="模型"
|
||||||
>
|
>
|
||||||
<button
|
{#each modelOptions as item}
|
||||||
type="button"
|
|
||||||
class="rounded-lg px-3 py-1.5 font-medium transition {mode === 'chat'
|
|
||||||
? 'bg-white text-gray-900 shadow-sm dark:bg-gray-700 dark:text-white'
|
|
||||||
: 'text-gray-500 hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-200'}"
|
|
||||||
on:click={() => chooseMode('chat')}
|
|
||||||
disabled={disabled || mode === 'work'}
|
|
||||||
title={mode === 'work' ? '此对话已升级为 Work,不能返回 Chat' : '快速对话'}
|
|
||||||
>
|
|
||||||
Chat
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="rounded-lg px-3 py-1.5 font-medium transition {mode === 'work'
|
|
||||||
? 'bg-white text-gray-900 shadow-sm dark:bg-gray-700 dark:text-white'
|
|
||||||
: 'text-gray-500 hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-200'}"
|
|
||||||
on:click={() => chooseMode('work')}
|
|
||||||
{disabled}
|
|
||||||
title="长链路、多步骤工作"
|
|
||||||
>
|
|
||||||
Work
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="flex items-center rounded-xl border border-gray-200/80 p-1 text-xs dark:border-gray-700/80"
|
|
||||||
aria-label="Inference strength"
|
|
||||||
>
|
|
||||||
{#each strengths as item}
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="rounded-lg px-2.5 py-1.5 transition {strength === item.key
|
class="whitespace-nowrap rounded-lg px-2.5 py-1.5 font-medium transition {selected === item.id
|
||||||
? 'bg-gray-900 text-white dark:bg-white dark:text-gray-900'
|
? 'bg-gray-900 text-white dark:bg-white dark:text-gray-900'
|
||||||
: 'text-gray-500 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-800'}"
|
: 'text-gray-500 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-800'}"
|
||||||
on:click={() => chooseStrength(item.key)}
|
on:click={() => chooseModel(item.id)}
|
||||||
{disabled}
|
disabled={disabled || ($models.length > 0 && !availableIds.has(item.id))}
|
||||||
|
title={item.id === 'deepseek-v4-pro' ? 'DeepSeek V4 Pro' : item.label}
|
||||||
>
|
>
|
||||||
{item.label}
|
{item.label}
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="whitespace-nowrap rounded-xl bg-gray-100/90 px-2.5 py-2 text-xs text-gray-500 dark:bg-gray-800/90 dark:text-gray-400"
|
||||||
|
title="模型与思考能力是两个独立概念"
|
||||||
|
>
|
||||||
|
思考 <span class="font-medium text-gray-800 dark:text-gray-200">{selectedOption.thinking}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="rounded-xl border border-gray-200/80 p-2 text-gray-500 transition hover:bg-gray-100 hover:text-gray-900 dark:border-gray-700/80 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-white"
|
class="rounded-xl border border-gray-200/80 p-2 text-gray-500 transition hover:bg-gray-100 hover:text-gray-900 dark:border-gray-700/80 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-white"
|
||||||
|
|||||||
+15
-27
@@ -1,25 +1,12 @@
|
|||||||
# Architecture
|
# Architecture
|
||||||
|
|
||||||
## Product modes
|
## Single Agent path
|
||||||
|
|
||||||
Chat and Work share the same authenticated user, conversation history, and
|
Open WebUI owns authenticated users, conversation history, and rendering.
|
||||||
workspace, but not the same Agent loop.
|
Every normal model request is delegated to the K1412 Runtime. There is no
|
||||||
|
second Chat loop and no per-conversation mode state.
|
||||||
|
|
||||||
| Concern | Chat | Work |
|
## Agent loop
|
||||||
| --- | --- | --- |
|
|
||||||
| Primary purpose | Fast conversation | Multi-step deliverables |
|
|
||||||
| Loop owner | Open WebUI native loop | K1412 Agent Runtime |
|
|
||||||
| Tools | Workspace Gateway through OpenAPI | Workspace Gateway through Runtime |
|
|
||||||
| Visible history | Open WebUI | Open WebUI |
|
|
||||||
| Run events | Open WebUI | Runtime event store |
|
|
||||||
| Context and memory | Open WebUI defaults are disabled by policy | Runtime policy and store |
|
|
||||||
| Workspace files | Authenticated K1412 file browser | Same file browser and volume |
|
|
||||||
|
|
||||||
Selecting Work atomically upgrades `(user_id, chat_id)` in the Runtime store.
|
|
||||||
Subsequent Chat requests for that conversation are rejected even if a client
|
|
||||||
tries to bypass the disabled UI control.
|
|
||||||
|
|
||||||
## Work loop
|
|
||||||
|
|
||||||
The first strategy is intentionally modular:
|
The first strategy is intentionally modular:
|
||||||
|
|
||||||
@@ -27,21 +14,22 @@ The first strategy is intentionally modular:
|
|||||||
- `ContextPolicy` owns visible-message cleanup and compaction.
|
- `ContextPolicy` owns visible-message cleanup and compaction.
|
||||||
- `ToolRegistry` owns tool schemas and dispatch.
|
- `ToolRegistry` owns tool schemas and dispatch.
|
||||||
- `AgentLoop` owns iteration, safe parallel scheduling, and delegation.
|
- `AgentLoop` owns iteration, safe parallel scheduling, and delegation.
|
||||||
- `RuntimeStore` owns modes, events, plans, and durable Work memory.
|
- `RuntimeStore` owns events, plans, and durable Agent memory.
|
||||||
- `ExecutionProvider` owns local or SSH Docker execution.
|
- `ExecutionProvider` owns local or SSH Docker execution.
|
||||||
|
|
||||||
Every run records its model tier, strategy version, scheduler version, context
|
Every run records its model ID, strategy version, scheduler version, context
|
||||||
policy, model/tool events, failures, and completion. This event stream is the
|
policy, model/tool events, failures, and completion. This event stream is the
|
||||||
foundation for replay, evaluation, and future experiment assignment.
|
foundation for replay, evaluation, and future experiment assignment.
|
||||||
|
|
||||||
The light, medium, and high tiers use the internal K1412 model endpoint. The
|
Luna, Terra, and Sol are three independent Ollama models. Each advertises
|
||||||
extreme tier uses DeepSeek V4 Pro with thinking enabled and maximum reasoning
|
boolean thinking support, but none advertises adjustable reasoning effort.
|
||||||
effort. Provider selection and credentials remain server-side. Thinking state
|
DeepSeek V4 Pro is a fourth model configured with thinking enabled and maximum
|
||||||
is preserved across tool turns because DeepSeek requires the assistant's
|
reasoning effort. Provider selection and credentials remain server-side.
|
||||||
`reasoning_content` to be returned with the following tool results; Work never
|
Thinking state is preserved across tool turns when the provider requires the
|
||||||
publishes that hidden state as its own user-facing reasoning.
|
assistant's `reasoning_content` to be returned with following tool results.
|
||||||
|
The Agent never publishes hidden reasoning as its own user-facing output.
|
||||||
|
|
||||||
Work completion is evidence-gated. Requests for scripts, reports, code, or
|
Completion is evidence-gated. Requests for scripts, reports, code, or
|
||||||
other concrete artifacts are not accepted as complete until a file mutation
|
other concrete artifacts are not accepted as complete until a file mutation
|
||||||
has succeeded and a later read, diff, or command has verified the latest
|
has succeeded and a later read, diff, or command has verified the latest
|
||||||
mutation. Bare interactive shell commands are rejected. If the iteration
|
mutation. Bare interactive shell commands are rejected. If the iteration
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ them does not change the Agent loop:
|
|||||||
| k1412 homepage | Service discovery entry |
|
| k1412 homepage | Service discovery entry |
|
||||||
| Tailscale | Private NAS-to-execution-host network |
|
| Tailscale | Private NAS-to-execution-host network |
|
||||||
| Unraid Compose Manager | Application lifecycle on the NAS |
|
| Unraid Compose Manager | Application lifecycle on the NAS |
|
||||||
|
| Ollama + 32 GB NVIDIA GPU | Luna, Terra, and Sol model serving |
|
||||||
|
|
||||||
Other NAS applications are independent tenants. They are not dependencies of
|
Other NAS applications are independent tenants. They are not dependencies of
|
||||||
K1412 Agent merely because they share the host.
|
K1412 Agent merely because they share the host.
|
||||||
@@ -35,8 +36,8 @@ K1412 Agent merely because they share the host.
|
|||||||
|
|
||||||
| Container | Responsibility | Agent core? |
|
| Container | Responsibility | Agent core? |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `k1412-agent-web` | Open WebUI auth, RBAC, history, UI, Chat loop | Product shell |
|
| `k1412-agent-web` | Open WebUI auth, RBAC, history, and UI | Product shell |
|
||||||
| `k1412-agent-runtime` | Work loop and provider gateway | Yes |
|
| `k1412-agent-runtime` | Agent loop and provider gateway | Yes |
|
||||||
| `k1412-agent-gateway` | Identity, execution policy, workspace lifecycle | Yes |
|
| `k1412-agent-gateway` | Identity, execution policy, workspace lifecycle | Yes |
|
||||||
| `k1412-agent-postgres` | Open WebUI and Runtime durable state | State |
|
| `k1412-agent-postgres` | Open WebUI and Runtime durable state | State |
|
||||||
| `k1412-agent-redis` | Open WebUI coordination/cache | State |
|
| `k1412-agent-redis` | Open WebUI coordination/cache | State |
|
||||||
@@ -68,7 +69,7 @@ The code intended for continuous experimentation is:
|
|||||||
- `agent_platform/runtime/loop.py`: loop and evidence policy;
|
- `agent_platform/runtime/loop.py`: loop and evidence policy;
|
||||||
- `agent_platform/runtime/context.py`: context policy;
|
- `agent_platform/runtime/context.py`: context policy;
|
||||||
- `agent_platform/runtime/tools.py`: tools and scheduling metadata;
|
- `agent_platform/runtime/tools.py`: tools and scheduling metadata;
|
||||||
- `agent_platform/store.py`: events, plans, mode state, memory;
|
- `agent_platform/store.py`: events, plans, and memory;
|
||||||
- `agent_platform/gateway/`: isolated execution providers.
|
- `agent_platform/gateway/`: isolated execution providers.
|
||||||
|
|
||||||
Frontend branding, reverse proxy configuration, and registry automation are
|
Frontend branding, reverse proxy configuration, and registry automation are
|
||||||
|
|||||||
@@ -3,14 +3,13 @@
|
|||||||
## Responsibility split
|
## Responsibility split
|
||||||
|
|
||||||
Open WebUI is the product shell. It owns account registration, login sessions,
|
Open WebUI is the product shell. It owns account registration, login sessions,
|
||||||
RBAC, administrator approval, conversation history, rendering, and the Chat
|
RBAC, administrator approval, conversation history, and rendering. K1412 does
|
||||||
mode Agent loop. K1412 does not fork those responsibilities into a second
|
not fork those responsibilities into a second authentication or chat database.
|
||||||
authentication or chat database.
|
|
||||||
|
|
||||||
K1412 Runtime owns Work mode: context selection, durable Work memory, planning,
|
K1412 Runtime owns the only user-facing Agent loop: context selection, durable
|
||||||
tool schemas, scheduling, parallel reads, child Agents, evidence gating,
|
memory, planning, tool schemas, scheduling, parallel reads, child Agents,
|
||||||
experiments, and run-event persistence. Workspace Gateway owns identity-bound
|
evidence gating, experiments, and run-event persistence. Workspace Gateway
|
||||||
execution policy and Docker lifecycle.
|
owns identity-bound execution policy and Docker lifecycle.
|
||||||
|
|
||||||
This split makes the expensive experimental surface small. A new scheduler or
|
This split makes the expensive experimental surface small. A new scheduler or
|
||||||
context policy changes Runtime, not the account system or entire frontend.
|
context policy changes Runtime, not the account system or entire frontend.
|
||||||
@@ -19,7 +18,7 @@ context policy changes Runtime, not the account system or entire frontend.
|
|||||||
|
|
||||||
Open WebUI natively understands tool-call and reasoning detail blocks. It also
|
Open WebUI natively understands tool-call and reasoning detail blocks. It also
|
||||||
has a richer live status mechanism for loops that execute inside its own
|
has a richer live status mechanism for loops that execute inside its own
|
||||||
backend. Work is an external OpenAI-compatible provider, so it cannot mutate
|
backend. K1412 Runtime is an external OpenAI-compatible provider, so it cannot mutate
|
||||||
Open WebUI's internal socket status objects directly.
|
Open WebUI's internal socket status objects directly.
|
||||||
|
|
||||||
K1412 therefore emits Open WebUI-compatible completed tool detail blocks. Each
|
K1412 therefore emits Open WebUI-compatible completed tool detail blocks. Each
|
||||||
@@ -52,8 +51,8 @@ Upgrading Open WebUI requires:
|
|||||||
1. updating the tag and commit pin;
|
1. updating the tag and commit pin;
|
||||||
2. replaying every patch with `git apply`;
|
2. replaying every patch with `git apply`;
|
||||||
3. building the Svelte production bundle;
|
3. building the Svelte production bundle;
|
||||||
4. running authentication, Chat, Work, and file-browser E2E tests;
|
4. running authentication, Agent-loop, isolation, and file-browser E2E tests;
|
||||||
5. reviewing upstream license changes.
|
5. reviewing upstream license changes.
|
||||||
|
|
||||||
The K1412 Agent loop is not embedded into Open WebUI, so most Work experiments
|
The K1412 Agent loop is not embedded into Open WebUI, so most Agent experiments
|
||||||
do not incur this upgrade cost.
|
do not incur this upgrade cost.
|
||||||
|
|||||||
+27
-4
@@ -41,12 +41,35 @@ proxy.
|
|||||||
|
|
||||||
Set `DEEPSEEK_API_BASE_URL=https://api.deepseek.com` and place
|
Set `DEEPSEEK_API_BASE_URL=https://api.deepseek.com` and place
|
||||||
`DEEPSEEK_API_KEY` only in the protected deployment `.env`. The key is used
|
`DEEPSEEK_API_KEY` only in the protected deployment `.env`. The key is used
|
||||||
only by Runtime for the extreme tier and must never be added to a browser,
|
only by Runtime for DeepSeek V4 Pro and must never be added to a browser,
|
||||||
Compose file, image, repository, or log.
|
Compose file, image, repository, or log.
|
||||||
|
|
||||||
The SSH override removes `/var/run/docker.sock` from Gateway and mounts the
|
The SSH override removes `/var/run/docker.sock` from Gateway and mounts the
|
||||||
dedicated SSH directory read-only at `/root/.ssh`.
|
dedicated SSH directory read-only at `/root/.ssh`.
|
||||||
|
|
||||||
|
## Ollama model residency
|
||||||
|
|
||||||
|
The NAS Ollama service keeps Luna, Terra, and Sol resident with:
|
||||||
|
|
||||||
|
- `OLLAMA_KEEP_ALIVE=-1`;
|
||||||
|
- `OLLAMA_MAX_LOADED_MODELS=3`;
|
||||||
|
- `OLLAMA_NUM_PARALLEL=1`;
|
||||||
|
- `OLLAMA_GPU_OVERHEAD=2147483648`;
|
||||||
|
- Flash Attention and `q8_0` KV cache enabled.
|
||||||
|
|
||||||
|
The three 32K-context runners were measured at approximately 2.88 GB, 4.12 GB,
|
||||||
|
and 18.44 GB of GPU allocation respectively. Their combined allocation is
|
||||||
|
about 25.44 GB on the 32 GB GPU. Do not increase context size or per-model
|
||||||
|
parallelism without repeating the simultaneous-load test and leaving capacity
|
||||||
|
for inference overhead.
|
||||||
|
|
||||||
|
The persistent Unraid template is
|
||||||
|
`/boot/config/plugins/dockerMan/templates-user/my-ollama.xml`. A locked,
|
||||||
|
idempotent five-minute check in
|
||||||
|
`/boot/config/plugins/dynamix/k1412-ollama.cron` prewarms only missing models
|
||||||
|
after a host or container restart. Confirm `/api/ps` lists all three with an
|
||||||
|
infinite expiry after recovery.
|
||||||
|
|
||||||
## Workspace migration
|
## Workspace migration
|
||||||
|
|
||||||
Before changing execution hosts:
|
Before changing execution hosts:
|
||||||
@@ -78,9 +101,9 @@ RUN_DOCKER_INTEGRATION=1 .venv/bin/pytest tests/test_docker_workspace.py
|
|||||||
Production smoke checks must cover:
|
Production smoke checks must cover:
|
||||||
|
|
||||||
- anonymous requests are redirected/rejected;
|
- anonymous requests are redirected/rejected;
|
||||||
- an approved user can use Chat;
|
- an approved user sees exactly four Agent models;
|
||||||
- Chat can upgrade to Work but cannot downgrade;
|
- each model routes through the custom Agent loop;
|
||||||
- Work creates and verifies a real file;
|
- the Agent creates and verifies a real file;
|
||||||
- the file browser lists and downloads that file;
|
- the file browser lists and downloads that file;
|
||||||
- a second user cannot see it;
|
- a second user cannot see it;
|
||||||
- Gateway health reports `ssh-docker`;
|
- Gateway health reports `ssh-docker`;
|
||||||
|
|||||||
@@ -69,12 +69,11 @@ class StubHandler(BaseHTTPRequestHandler):
|
|||||||
available_names = {
|
available_names = {
|
||||||
str((tool.get("function") or {}).get("name", "")) for tool in tools if isinstance(tool, dict)
|
str((tool.get("function") or {}).get("name", "")) for tool in tools if isinstance(tool, dict)
|
||||||
}
|
}
|
||||||
is_work = "update_plan" in available_names
|
is_agent = "update_plan" in available_names
|
||||||
|
|
||||||
if tools and not has_tool_result:
|
if tools and not has_tool_result:
|
||||||
path = "work-proof.txt" if is_work else "chat-proof.txt"
|
|
||||||
arguments = json.dumps(
|
arguments = json.dumps(
|
||||||
{"path": path, "content": _last_user_text(messages) or "e2e"},
|
{"path": "work-proof.txt", "content": _last_user_text(messages) or "e2e"},
|
||||||
ensure_ascii=False,
|
ensure_ascii=False,
|
||||||
)
|
)
|
||||||
message = {
|
message = {
|
||||||
@@ -91,7 +90,7 @@ class StubHandler(BaseHTTPRequestHandler):
|
|||||||
self._respond(payload, _completion(model, message, "tool_calls"))
|
self._respond(payload, _completion(model, message, "tool_calls"))
|
||||||
return
|
return
|
||||||
|
|
||||||
if is_work and "write_file" in called_tools and "read_file" not in called_tools:
|
if is_agent and "write_file" in called_tools and "read_file" not in called_tools:
|
||||||
message = {
|
message = {
|
||||||
"role": "assistant",
|
"role": "assistant",
|
||||||
"content": None,
|
"content": None,
|
||||||
|
|||||||
+24
-36
@@ -26,14 +26,10 @@ INTERNAL_PROVIDER_KEY = os.getenv(
|
|||||||
"e2e-provider-secret-at-least-32-bytes",
|
"e2e-provider-secret-at-least-32-bytes",
|
||||||
)
|
)
|
||||||
EXPECTED_MODELS = {
|
EXPECTED_MODELS = {
|
||||||
"chat-light",
|
"luna",
|
||||||
"chat-medium",
|
"terra",
|
||||||
"chat-high",
|
"sol",
|
||||||
"chat-extreme",
|
"deepseek-v4-pro",
|
||||||
"work-light",
|
|
||||||
"work-medium",
|
|
||||||
"work-high",
|
|
||||||
"work-extreme",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -56,7 +52,6 @@ def _stream_chat(
|
|||||||
model: str,
|
model: str,
|
||||||
chat_id: str,
|
chat_id: str,
|
||||||
prompt: str,
|
prompt: str,
|
||||||
include_workspace_tools: bool,
|
|
||||||
) -> tuple[int, str]:
|
) -> tuple[int, str]:
|
||||||
user_message_id = uuid.uuid4().hex
|
user_message_id = uuid.uuid4().hex
|
||||||
assistant_message_id = uuid.uuid4().hex
|
assistant_message_id = uuid.uuid4().hex
|
||||||
@@ -75,11 +70,9 @@ def _stream_chat(
|
|||||||
"content": prompt,
|
"content": prompt,
|
||||||
"timestamp": int(time.time()),
|
"timestamp": int(time.time()),
|
||||||
},
|
},
|
||||||
"params": {"function_calling": "native"},
|
"params": {},
|
||||||
"features": {},
|
"features": {},
|
||||||
}
|
}
|
||||||
if include_workspace_tools:
|
|
||||||
payload["tool_ids"] = ["server:workspace"]
|
|
||||||
with client.stream(
|
with client.stream(
|
||||||
"POST",
|
"POST",
|
||||||
"/api/chat/completions",
|
"/api/chat/completions",
|
||||||
@@ -208,37 +201,25 @@ def main() -> None:
|
|||||||
|
|
||||||
chat_ids = [f"local:e2e-{suffix}-a", f"local:e2e-{suffix}-b"]
|
chat_ids = [f"local:e2e-{suffix}-a", f"local:e2e-{suffix}-b"]
|
||||||
for index, user in enumerate(users):
|
for index, user in enumerate(users):
|
||||||
marker = f"E2E_CHAT_USER_{index}_{suffix}"
|
marker = f"E2E_AGENT_USER_{index}_{suffix}"
|
||||||
status_code, body = _stream_chat(
|
status_code, body = _stream_chat(
|
||||||
client,
|
client,
|
||||||
user["token"],
|
user["token"],
|
||||||
model="chat-light",
|
model="luna" if index == 0 else "terra",
|
||||||
chat_id=chat_ids[index],
|
chat_id=chat_ids[index],
|
||||||
prompt=marker,
|
prompt=marker,
|
||||||
include_workspace_tools=True,
|
|
||||||
)
|
)
|
||||||
assert status_code == 200, body
|
assert status_code == 200, body
|
||||||
|
|
||||||
docker_client = docker.from_env()
|
docker_client = docker.from_env()
|
||||||
assert _workspace_file(docker_client, users[0]["id"], "chat-proof.txt") == f"E2E_CHAT_USER_0_{suffix}"
|
assert _workspace_file(docker_client, users[0]["id"], "work-proof.txt") == f"E2E_AGENT_USER_0_{suffix}"
|
||||||
assert _workspace_file(docker_client, users[1]["id"], "chat-proof.txt") == f"E2E_CHAT_USER_1_{suffix}"
|
assert _workspace_file(docker_client, users[1]["id"], "work-proof.txt") == f"E2E_AGENT_USER_1_{suffix}"
|
||||||
for user in users:
|
for user in users:
|
||||||
ref = workspace_ref(user["id"])
|
ref = workspace_ref(user["id"])
|
||||||
workspace = docker_client.containers.get(ref.container_name)
|
workspace = docker_client.containers.get(ref.container_name)
|
||||||
workspace.reload()
|
workspace.reload()
|
||||||
assert set(workspace.attrs["NetworkSettings"]["Networks"]) == {ref.network_name}
|
assert set(workspace.attrs["NetworkSettings"]["Networks"]) == {ref.network_name}
|
||||||
|
|
||||||
work_marker = f"E2E_WORK_USER_0_{suffix}"
|
|
||||||
status_code, work_body = _stream_chat(
|
|
||||||
client,
|
|
||||||
users[0]["token"],
|
|
||||||
model="work-medium",
|
|
||||||
chat_id=chat_ids[0],
|
|
||||||
prompt=work_marker,
|
|
||||||
include_workspace_tools=False,
|
|
||||||
)
|
|
||||||
assert status_code == 200, work_body
|
|
||||||
assert _workspace_file(docker_client, users[0]["id"], "work-proof.txt") == work_marker
|
|
||||||
listing = client.get(
|
listing = client.get(
|
||||||
"/api/v1/k1412/workspace/files",
|
"/api/v1/k1412/workspace/files",
|
||||||
headers=_auth(users[0]["token"]),
|
headers=_auth(users[0]["token"]),
|
||||||
@@ -252,14 +233,21 @@ def main() -> None:
|
|||||||
params={"path": "work-proof.txt"},
|
params={"path": "work-proof.txt"},
|
||||||
)
|
)
|
||||||
download.raise_for_status()
|
download.raise_for_status()
|
||||||
assert download.text == work_marker
|
assert download.text == f"E2E_AGENT_USER_0_{suffix}"
|
||||||
isolated_listing = client.get(
|
isolated_listing = client.get(
|
||||||
"/api/v1/k1412/workspace/files",
|
"/api/v1/k1412/workspace/files",
|
||||||
headers=_auth(users[1]["token"]),
|
headers=_auth(users[1]["token"]),
|
||||||
params={"path": "."},
|
params={"path": "."},
|
||||||
)
|
)
|
||||||
isolated_listing.raise_for_status()
|
isolated_listing.raise_for_status()
|
||||||
assert "work-proof.txt" not in {item["name"] for item in isolated_listing.json()["entries"]}
|
assert "work-proof.txt" in {item["name"] for item in isolated_listing.json()["entries"]}
|
||||||
|
isolated_download = client.get(
|
||||||
|
"/api/v1/k1412/workspace/download",
|
||||||
|
headers=_auth(users[1]["token"]),
|
||||||
|
params={"path": "work-proof.txt"},
|
||||||
|
)
|
||||||
|
isolated_download.raise_for_status()
|
||||||
|
assert isolated_download.text == f"E2E_AGENT_USER_1_{suffix}"
|
||||||
archive = client.get(
|
archive = client.get(
|
||||||
"/api/v1/k1412/workspace/archive",
|
"/api/v1/k1412/workspace/archive",
|
||||||
headers=_auth(users[0]["token"]),
|
headers=_auth(users[0]["token"]),
|
||||||
@@ -276,20 +264,20 @@ def main() -> None:
|
|||||||
event_types = {event["type"] for event in events.json()["items"]}
|
event_types = {event["type"] for event in events.json()["items"]}
|
||||||
assert {"run.created", "tool.completed", "run.completed"} <= event_types
|
assert {"run.created", "tool.completed", "run.completed"} <= event_types
|
||||||
|
|
||||||
downgrade = runtime.post(
|
legacy = runtime.post(
|
||||||
"/v1/chat/completions",
|
"/v1/chat/completions",
|
||||||
headers=runtime_headers,
|
headers=runtime_headers,
|
||||||
json={
|
json={
|
||||||
"model": "chat-high",
|
"model": "work-high",
|
||||||
"messages": [{"role": "user", "content": "THIS_DOWNGRADE_MUST_FAIL"}],
|
"messages": [{"role": "user", "content": "LEGACY_MODEL_MUST_FAIL"}],
|
||||||
"stream": False,
|
"stream": False,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
assert downgrade.status_code == 409, downgrade.text
|
assert legacy.status_code == 404, legacy.text
|
||||||
|
|
||||||
print(
|
print(
|
||||||
"E2E passed: auth approval, eight models, Chat tools, Work evidence, file downloads, "
|
"E2E passed: auth approval, four Agent models, custom loop evidence, file downloads, "
|
||||||
"isolation, and downgrade lock."
|
"and per-user workspace isolation."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -119,9 +119,9 @@ class DirectDockerRegistry:
|
|||||||
|
|
||||||
def parse_args() -> argparse.Namespace:
|
def parse_args() -> argparse.Namespace:
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
description="Run the real Work loop against a live provider in a disposable Docker workspace."
|
description="Run the real Agent loop against a live provider in a disposable Docker workspace."
|
||||||
)
|
)
|
||||||
parser.add_argument("--model", default="work-extreme")
|
parser.add_argument("--model", default="deepseek-v4-pro")
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--prompt",
|
"--prompt",
|
||||||
default="写脚本对比一下几个常见排序算法,给一个报告",
|
default="写脚本对比一下几个常见排序算法,给一个报告",
|
||||||
@@ -153,8 +153,6 @@ async def cleanup_workspace(execution: DockerExecutionProvider, user_id: str) ->
|
|||||||
async def main() -> None:
|
async def main() -> None:
|
||||||
args = parse_args()
|
args = parse_args()
|
||||||
spec = get_model_spec(args.model)
|
spec = get_model_spec(args.model)
|
||||||
if spec.mode != "work":
|
|
||||||
raise SystemExit("--model must select a Work tier")
|
|
||||||
if spec.provider == "deepseek" and not os.getenv("DEEPSEEK_API_KEY", "").strip():
|
if spec.provider == "deepseek" and not os.getenv("DEEPSEEK_API_KEY", "").strip():
|
||||||
raise SystemExit("DEEPSEEK_API_KEY is required")
|
raise SystemExit("DEEPSEEK_API_KEY is required")
|
||||||
|
|
||||||
|
|||||||
+14
-14
@@ -201,7 +201,7 @@ async def test_tool_call_batch_is_bounded(settings) -> None:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
answer = await loop.run(
|
answer = await loop.run(
|
||||||
spec=get_model_spec("work-medium"),
|
spec=get_model_spec("terra"),
|
||||||
messages=[{"role": "user", "content": "检查当前状态"}],
|
messages=[{"role": "user", "content": "检查当前状态"}],
|
||||||
identity=UserIdentity("u1", "u1@example.test", "U1", "user"),
|
identity=UserIdentity("u1", "u1@example.test", "U1", "user"),
|
||||||
raw_user_jwt="jwt",
|
raw_user_jwt="jwt",
|
||||||
@@ -245,7 +245,7 @@ async def test_read_only_tool_calls_run_in_parallel(settings) -> None:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
answer = await loop.run(
|
answer = await loop.run(
|
||||||
spec=get_model_spec("work-light"),
|
spec=get_model_spec("luna"),
|
||||||
messages=[{"role": "user", "content": "inspect both"}],
|
messages=[{"role": "user", "content": "inspect both"}],
|
||||||
identity=UserIdentity("u1", "", "", "user"),
|
identity=UserIdentity("u1", "", "", "user"),
|
||||||
raw_user_jwt="jwt",
|
raw_user_jwt="jwt",
|
||||||
@@ -314,7 +314,7 @@ async def test_mutating_tool_calls_are_serialized(settings) -> None:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
await loop.run(
|
await loop.run(
|
||||||
spec=get_model_spec("work-light"),
|
spec=get_model_spec("luna"),
|
||||||
messages=[{"role": "user", "content": "write both"}],
|
messages=[{"role": "user", "content": "write both"}],
|
||||||
identity=UserIdentity("u1", "", "", "user"),
|
identity=UserIdentity("u1", "", "", "user"),
|
||||||
raw_user_jwt="jwt",
|
raw_user_jwt="jwt",
|
||||||
@@ -374,7 +374,7 @@ async def test_artifact_completion_is_rejected_until_written_and_verified(settin
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
answer = await AgentLoop(provider, registry, store, max_tool_output_chars=10_000).run(
|
answer = await AgentLoop(provider, registry, store, max_tool_output_chars=10_000).run(
|
||||||
spec=get_model_spec("work-light"),
|
spec=get_model_spec("luna"),
|
||||||
messages=[{"role": "user", "content": "写一个报告"}],
|
messages=[{"role": "user", "content": "写一个报告"}],
|
||||||
identity=UserIdentity("u1", "", "", "user"),
|
identity=UserIdentity("u1", "", "", "user"),
|
||||||
raw_user_jwt="jwt",
|
raw_user_jwt="jwt",
|
||||||
@@ -469,7 +469,7 @@ async def test_script_and_report_completion_requires_both_files(settings) -> Non
|
|||||||
store,
|
store,
|
||||||
max_tool_output_chars=10_000,
|
max_tool_output_chars=10_000,
|
||||||
).run(
|
).run(
|
||||||
spec=get_model_spec("work-light"),
|
spec=get_model_spec("luna"),
|
||||||
messages=[{"role": "user", "content": "写一个脚本并给一个报告"}],
|
messages=[{"role": "user", "content": "写一个脚本并给一个报告"}],
|
||||||
identity=UserIdentity("u1", "", "", "user"),
|
identity=UserIdentity("u1", "", "", "user"),
|
||||||
raw_user_jwt="jwt",
|
raw_user_jwt="jwt",
|
||||||
@@ -575,7 +575,7 @@ async def test_failed_execution_must_be_repaired_before_completion(settings) ->
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
answer = await AgentLoop(provider, registry, store, max_tool_output_chars=10_000).run(
|
answer = await AgentLoop(provider, registry, store, max_tool_output_chars=10_000).run(
|
||||||
spec=get_model_spec("work-light"),
|
spec=get_model_spec("luna"),
|
||||||
messages=[{"role": "user", "content": "写脚本对比排序算法"}],
|
messages=[{"role": "user", "content": "写脚本对比排序算法"}],
|
||||||
identity=UserIdentity("u1", "", "", "user"),
|
identity=UserIdentity("u1", "", "", "user"),
|
||||||
raw_user_jwt="jwt",
|
raw_user_jwt="jwt",
|
||||||
@@ -662,7 +662,7 @@ async def test_unchanged_failed_command_is_blocked_until_a_repair(settings) -> N
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
answer = await AgentLoop(provider, registry, store, max_tool_output_chars=10_000).run(
|
answer = await AgentLoop(provider, registry, store, max_tool_output_chars=10_000).run(
|
||||||
spec=get_model_spec("work-light"),
|
spec=get_model_spec("luna"),
|
||||||
messages=[{"role": "user", "content": "写脚本并运行"}],
|
messages=[{"role": "user", "content": "写脚本并运行"}],
|
||||||
identity=UserIdentity("u1", "", "", "user"),
|
identity=UserIdentity("u1", "", "", "user"),
|
||||||
raw_user_jwt="jwt",
|
raw_user_jwt="jwt",
|
||||||
@@ -707,7 +707,7 @@ async def test_duplicate_commands_in_one_model_response_execute_once(settings) -
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
answer = await AgentLoop(provider, registry, store, max_tool_output_chars=10_000).run(
|
answer = await AgentLoop(provider, registry, store, max_tool_output_chars=10_000).run(
|
||||||
spec=get_model_spec("work-light"),
|
spec=get_model_spec("luna"),
|
||||||
messages=[{"role": "user", "content": "写脚本并运行"}],
|
messages=[{"role": "user", "content": "写脚本并运行"}],
|
||||||
identity=UserIdentity("u1", "", "", "user"),
|
identity=UserIdentity("u1", "", "", "user"),
|
||||||
raw_user_jwt="jwt",
|
raw_user_jwt="jwt",
|
||||||
@@ -753,7 +753,7 @@ async def test_duplicate_identical_writes_execute_once(settings) -> None:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
answer = await AgentLoop(provider, registry, store, max_tool_output_chars=10_000).run(
|
answer = await AgentLoop(provider, registry, store, max_tool_output_chars=10_000).run(
|
||||||
spec=get_model_spec("work-light"),
|
spec=get_model_spec("luna"),
|
||||||
messages=[{"role": "user", "content": "写脚本并运行"}],
|
messages=[{"role": "user", "content": "写脚本并运行"}],
|
||||||
identity=UserIdentity("u1", "", "", "user"),
|
identity=UserIdentity("u1", "", "", "user"),
|
||||||
raw_user_jwt="jwt",
|
raw_user_jwt="jwt",
|
||||||
@@ -815,7 +815,7 @@ async def test_script_delivery_requires_successful_execution(settings) -> None:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
answer = await AgentLoop(provider, registry, store, max_tool_output_chars=10_000).run(
|
answer = await AgentLoop(provider, registry, store, max_tool_output_chars=10_000).run(
|
||||||
spec=get_model_spec("work-light"),
|
spec=get_model_spec("luna"),
|
||||||
messages=[{"role": "user", "content": "写一个 Python 脚本"}],
|
messages=[{"role": "user", "content": "写一个 Python 脚本"}],
|
||||||
identity=UserIdentity("u1", "", "", "user"),
|
identity=UserIdentity("u1", "", "", "user"),
|
||||||
raw_user_jwt="jwt",
|
raw_user_jwt="jwt",
|
||||||
@@ -846,7 +846,7 @@ async def test_rejected_checkpoint_does_not_spin_without_tool_calls(settings) ->
|
|||||||
store,
|
store,
|
||||||
max_tool_output_chars=10_000,
|
max_tool_output_chars=10_000,
|
||||||
).run(
|
).run(
|
||||||
spec=get_model_spec("work-light"),
|
spec=get_model_spec("luna"),
|
||||||
messages=[{"role": "user", "content": "写一个报告"}],
|
messages=[{"role": "user", "content": "写一个报告"}],
|
||||||
identity=UserIdentity("u1", "", "", "user"),
|
identity=UserIdentity("u1", "", "", "user"),
|
||||||
raw_user_jwt="jwt",
|
raw_user_jwt="jwt",
|
||||||
@@ -888,7 +888,7 @@ async def test_bare_interactive_exec_is_blocked_before_registry(settings) -> Non
|
|||||||
store,
|
store,
|
||||||
max_tool_output_chars=10_000,
|
max_tool_output_chars=10_000,
|
||||||
).run(
|
).run(
|
||||||
spec=get_model_spec("work-light"),
|
spec=get_model_spec("luna"),
|
||||||
messages=[{"role": "user", "content": "你好"}],
|
messages=[{"role": "user", "content": "你好"}],
|
||||||
identity=UserIdentity("u1", "", "", "user"),
|
identity=UserIdentity("u1", "", "", "user"),
|
||||||
raw_user_jwt="jwt",
|
raw_user_jwt="jwt",
|
||||||
@@ -926,7 +926,7 @@ async def test_unchanged_invalid_python_write_is_not_revalidated(settings) -> No
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
answer = await AgentLoop(provider, registry, store, max_tool_output_chars=10_000).run(
|
answer = await AgentLoop(provider, registry, store, max_tool_output_chars=10_000).run(
|
||||||
spec=get_model_spec("work-light"),
|
spec=get_model_spec("luna"),
|
||||||
messages=[{"role": "user", "content": "你好"}],
|
messages=[{"role": "user", "content": "你好"}],
|
||||||
identity=UserIdentity("u1", "", "", "user"),
|
identity=UserIdentity("u1", "", "", "user"),
|
||||||
raw_user_jwt="jwt",
|
raw_user_jwt="jwt",
|
||||||
@@ -999,7 +999,7 @@ async def test_extreme_tier_preserves_reasoning_state_across_tool_turns(settings
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
answer = await AgentLoop(provider, registry, store, max_tool_output_chars=10_000).run(
|
answer = await AgentLoop(provider, registry, store, max_tool_output_chars=10_000).run(
|
||||||
spec=get_model_spec("work-extreme"),
|
spec=get_model_spec("deepseek-v4-pro"),
|
||||||
messages=[{"role": "user", "content": "读取 notes.txt 并检查内容"}],
|
messages=[{"role": "user", "content": "读取 notes.txt 并检查内容"}],
|
||||||
identity=UserIdentity("u1", "", "", "user"),
|
identity=UserIdentity("u1", "", "", "user"),
|
||||||
raw_user_jwt="jwt",
|
raw_user_jwt="jwt",
|
||||||
|
|||||||
+8
-12
@@ -1,20 +1,16 @@
|
|||||||
from agent_platform.bootstrap import _model_payload
|
from agent_platform.bootstrap import LEGACY_MODEL_IDS, _model_payload
|
||||||
|
|
||||||
|
|
||||||
def test_bootstrap_models_override_provider_ids_and_are_public() -> None:
|
def test_bootstrap_models_are_agent_only_and_public() -> None:
|
||||||
models = _model_payload()
|
models = _model_payload()
|
||||||
assert len(models) == 8
|
assert {model["id"] for model in models} == {"luna", "terra", "sol", "deepseek-v4-pro"}
|
||||||
assert all(model["base_model_id"] is None for model in models)
|
assert all(model["base_model_id"] is None for model in models)
|
||||||
|
assert all(model["params"] == {} for model in models)
|
||||||
|
assert all(model["meta"]["toolIds"] == [] for model in models)
|
||||||
|
assert all(model["meta"]["tags"] == [{"name": "Agent"}] for model in models)
|
||||||
assert all(
|
assert all(
|
||||||
model["access_grants"] == [{"principal_type": "user", "principal_id": "*", "permission": "read"}]
|
model["access_grants"] == [{"principal_type": "user", "principal_id": "*", "permission": "read"}]
|
||||||
for model in models
|
for model in models
|
||||||
)
|
)
|
||||||
|
assert "chat-medium" in LEGACY_MODEL_IDS
|
||||||
chat = next(model for model in models if model["id"] == "chat-medium")
|
assert "work-medium" in LEGACY_MODEL_IDS
|
||||||
assert chat["params"]["function_calling"] == "native"
|
|
||||||
assert chat["meta"]["toolIds"] == ["server:workspace"]
|
|
||||||
assert chat["meta"]["capabilities"]["builtin_tools"] is False
|
|
||||||
|
|
||||||
work = next(model for model in models if model["id"] == "work-medium")
|
|
||||||
assert work["params"] == {}
|
|
||||||
assert work["meta"]["toolIds"] == []
|
|
||||||
|
|||||||
+24
-20
@@ -1,23 +1,27 @@
|
|||||||
from agent_platform.models import MODEL_SPECS, get_model_spec, openai_model_list
|
from agent_platform.models import MODEL_SPECS, get_model_spec, openai_model_list
|
||||||
|
|
||||||
|
|
||||||
def test_fixed_public_model_matrix() -> None:
|
def test_fixed_public_model_catalog_separates_model_and_thinking() -> None:
|
||||||
assert set(MODEL_SPECS) == {
|
assert set(MODEL_SPECS) == {"luna", "terra", "sol", "deepseek-v4-pro"}
|
||||||
"chat-light",
|
assert get_model_spec("luna").provider_model == "ChatGPT-5.6:Luna"
|
||||||
"chat-medium",
|
assert get_model_spec("terra").provider_model == "ChatGPT-5.6:Terra"
|
||||||
"chat-high",
|
assert get_model_spec("sol").provider_model == "ChatGPT-5.6:Sol"
|
||||||
"chat-extreme",
|
|
||||||
"work-light",
|
for model_id in ("luna", "terra", "sol"):
|
||||||
"work-medium",
|
spec = get_model_spec(model_id)
|
||||||
"work-high",
|
assert spec.thinking_enabled is True
|
||||||
"work-extreme",
|
assert spec.thinking_adjustable is False
|
||||||
}
|
assert spec.thinking_label == "开启(不分档)"
|
||||||
assert get_model_spec("chat-light").provider_model == "ChatGPT-5.6:Luna"
|
assert spec.reasoning_effort is None
|
||||||
assert get_model_spec("work-medium").provider_model == "ChatGPT-5.6:Terra"
|
|
||||||
assert get_model_spec("work-high").provider_model == "ChatGPT-5.6:Sol"
|
deepseek = get_model_spec("deepseek-v4-pro")
|
||||||
extreme = get_model_spec("work-extreme")
|
assert deepseek.provider == "deepseek"
|
||||||
assert extreme.provider == "deepseek"
|
assert deepseek.provider_model == "deepseek-v4-pro"
|
||||||
assert extreme.provider_model == "deepseek-v4-pro"
|
assert deepseek.thinking_enabled is True
|
||||||
assert extreme.thinking_enabled is True
|
assert deepseek.thinking_adjustable is False
|
||||||
assert extreme.reasoning_effort == "max"
|
assert deepseek.thinking_label == "极高"
|
||||||
assert {item["id"] for item in openai_model_list()["data"]} == set(MODEL_SPECS)
|
assert deepseek.reasoning_effort == "max"
|
||||||
|
|
||||||
|
public_models = openai_model_list()["data"]
|
||||||
|
assert {item["id"] for item in public_models} == set(MODEL_SPECS)
|
||||||
|
assert all("k1412" in item for item in public_models)
|
||||||
|
|||||||
+44
-97
@@ -18,7 +18,7 @@ class FakeModelProvider:
|
|||||||
async def complete(self, **kwargs):
|
async def complete(self, **kwargs):
|
||||||
self.completed.append(kwargs)
|
self.completed.append(kwargs)
|
||||||
return {
|
return {
|
||||||
"choices": [{"message": {"role": "assistant", "content": "work complete"}, "finish_reason": "stop"}],
|
"choices": [{"message": {"role": "assistant", "content": "agent complete"}, "finish_reason": "stop"}],
|
||||||
"usage": {"total_tokens": 10},
|
"usage": {"total_tokens": 10},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,25 +26,13 @@ class FakeModelProvider:
|
|||||||
self.forwarded.append(payload)
|
self.forwarded.append(payload)
|
||||||
self.forward_options.append(kwargs)
|
self.forward_options.append(kwargs)
|
||||||
request = httpx.Request("POST", "https://model.example.test/v1/chat/completions")
|
request = httpx.Request("POST", "https://model.example.test/v1/chat/completions")
|
||||||
if payload.get("stream"):
|
|
||||||
return httpx.Response(
|
|
||||||
200,
|
|
||||||
request=request,
|
|
||||||
headers={"content-type": "text/event-stream"},
|
|
||||||
content=(
|
|
||||||
'data: {"model":"ChatGPT-5.6:Luna","choices":[{"index":0,"delta":'
|
|
||||||
'{"tool_calls":[{"index":0,"id":"call-1","type":"function","function":'
|
|
||||||
'{"name":"read_file","arguments":"{}"}}]},"finish_reason":null}]}\n\n'
|
|
||||||
"data: [DONE]\n\n"
|
|
||||||
),
|
|
||||||
)
|
|
||||||
return httpx.Response(
|
return httpx.Response(
|
||||||
200,
|
200,
|
||||||
request=request,
|
request=request,
|
||||||
headers={"content-type": "application/json"},
|
headers={"content-type": "application/json"},
|
||||||
json={
|
json={
|
||||||
"model": payload["model"],
|
"model": payload["model"],
|
||||||
"choices": [{"message": {"role": "assistant", "content": "chat reply"}, "finish_reason": "stop"}],
|
"choices": [{"message": {"role": "assistant", "content": "background reply"}}],
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -71,135 +59,94 @@ def headers(settings, identity_jwt, chat_id="chat-1"):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_model_list_requires_internal_key(settings) -> None:
|
def _app(settings, provider=None):
|
||||||
app = create_app(
|
return create_app(
|
||||||
settings,
|
settings,
|
||||||
store=RuntimeStore(settings.database_url),
|
store=RuntimeStore(settings.database_url),
|
||||||
provider=FakeModelProvider(),
|
provider=provider or FakeModelProvider(),
|
||||||
tools=FakeTools(),
|
tools=FakeTools(),
|
||||||
)
|
)
|
||||||
with TestClient(app) as client:
|
|
||||||
|
|
||||||
|
def test_model_list_requires_internal_key_and_exposes_four_models(settings) -> None:
|
||||||
|
with TestClient(_app(settings)) as client:
|
||||||
assert client.get("/v1/models").status_code == 401
|
assert client.get("/v1/models").status_code == 401
|
||||||
response = client.get(
|
response = client.get(
|
||||||
"/v1/models",
|
"/v1/models",
|
||||||
headers={"Authorization": f"Bearer {settings.internal_provider_key}"},
|
headers={"Authorization": f"Bearer {settings.internal_provider_key}"},
|
||||||
)
|
)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert len(response.json()["data"]) == 8
|
assert {item["id"] for item in response.json()["data"]} == {
|
||||||
|
"luna",
|
||||||
|
"terra",
|
||||||
def test_chat_maps_provider_model_and_work_upgrade_is_one_way(settings, identity_jwt) -> None:
|
"sol",
|
||||||
provider = FakeModelProvider()
|
"deepseek-v4-pro",
|
||||||
app = create_app(settings, store=RuntimeStore(settings.database_url), provider=provider, tools=FakeTools())
|
|
||||||
with TestClient(app) as client:
|
|
||||||
chat = client.post(
|
|
||||||
"/v1/chat/completions",
|
|
||||||
headers=headers(settings, identity_jwt),
|
|
||||||
json={"model": "chat-light", "messages": [{"role": "user", "content": "hello"}]},
|
|
||||||
)
|
|
||||||
assert chat.status_code == 200
|
|
||||||
assert provider.forwarded[0]["model"] == "ChatGPT-5.6:Luna"
|
|
||||||
assert chat.json()["model"] == "chat-light"
|
|
||||||
|
|
||||||
extreme_chat = client.post(
|
|
||||||
"/v1/chat/completions",
|
|
||||||
headers=headers(settings, identity_jwt, "extreme-chat"),
|
|
||||||
json={"model": "chat-extreme", "messages": [{"role": "user", "content": "think"}]},
|
|
||||||
)
|
|
||||||
assert extreme_chat.status_code == 200
|
|
||||||
assert provider.forwarded[-1]["model"] == "deepseek-v4-pro"
|
|
||||||
assert provider.forward_options[-1] == {
|
|
||||||
"provider": "deepseek",
|
|
||||||
"thinking_enabled": True,
|
|
||||||
"reasoning_effort": "max",
|
|
||||||
"max_output_tokens": 16_384,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
work = client.post(
|
|
||||||
"/v1/chat/completions",
|
|
||||||
headers=headers(settings, identity_jwt),
|
|
||||||
json={"model": "work-high", "messages": [{"role": "user", "content": "do it"}]},
|
|
||||||
)
|
|
||||||
assert work.status_code == 200
|
|
||||||
assert provider.completed[-1]["model"] == "ChatGPT-5.6:Sol"
|
|
||||||
|
|
||||||
extreme_work = client.post(
|
def test_every_user_model_runs_the_custom_agent_loop(settings, identity_jwt) -> None:
|
||||||
|
provider = FakeModelProvider()
|
||||||
|
with TestClient(_app(settings, provider)) as client:
|
||||||
|
luna = client.post(
|
||||||
"/v1/chat/completions",
|
"/v1/chat/completions",
|
||||||
headers=headers(settings, identity_jwt, "extreme-work"),
|
headers=headers(settings, identity_jwt, "luna-run"),
|
||||||
json={"model": "work-extreme", "messages": [{"role": "user", "content": "do it carefully"}]},
|
json={"model": "luna", "messages": [{"role": "user", "content": "hello"}]},
|
||||||
)
|
)
|
||||||
assert extreme_work.status_code == 200
|
assert luna.status_code == 200
|
||||||
|
assert luna.json()["model"] == "luna"
|
||||||
|
assert provider.completed[-1]["model"] == "ChatGPT-5.6:Luna"
|
||||||
|
assert provider.completed[-1]["thinking_enabled"] is True
|
||||||
|
assert provider.forwarded == []
|
||||||
|
|
||||||
|
deepseek = client.post(
|
||||||
|
"/v1/chat/completions",
|
||||||
|
headers=headers(settings, identity_jwt, "deepseek-run"),
|
||||||
|
json={"model": "deepseek-v4-pro", "messages": [{"role": "user", "content": "do it"}]},
|
||||||
|
)
|
||||||
|
assert deepseek.status_code == 200
|
||||||
assert provider.completed[-1]["model"] == "deepseek-v4-pro"
|
assert provider.completed[-1]["model"] == "deepseek-v4-pro"
|
||||||
assert provider.completed[-1]["provider"] == "deepseek"
|
assert provider.completed[-1]["provider"] == "deepseek"
|
||||||
assert provider.completed[-1]["reasoning_effort"] == "max"
|
assert provider.completed[-1]["reasoning_effort"] == "max"
|
||||||
|
|
||||||
downgrade = client.post(
|
legacy = client.post(
|
||||||
"/v1/chat/completions",
|
"/v1/chat/completions",
|
||||||
headers=headers(settings, identity_jwt),
|
headers=headers(settings, identity_jwt, "legacy-run"),
|
||||||
json={"model": "chat-medium", "messages": [{"role": "user", "content": "back"}]},
|
json={"model": "work-medium", "messages": [{"role": "user", "content": "old"}]},
|
||||||
)
|
)
|
||||||
assert downgrade.status_code == 409
|
assert legacy.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
def test_chat_stream_hides_provider_model_and_preserves_native_tool_calls(settings, identity_jwt) -> None:
|
def test_openwebui_background_task_bypasses_agent_loop(settings, identity_jwt) -> None:
|
||||||
provider = FakeModelProvider()
|
provider = FakeModelProvider()
|
||||||
app = create_app(settings, store=RuntimeStore(settings.database_url), provider=provider, tools=FakeTools())
|
with TestClient(_app(settings, provider)) as client:
|
||||||
with TestClient(app) as client:
|
|
||||||
response = client.post(
|
|
||||||
"/v1/chat/completions",
|
|
||||||
headers=headers(settings, identity_jwt, "chat-stream"),
|
|
||||||
json={
|
|
||||||
"model": "chat-light",
|
|
||||||
"stream": True,
|
|
||||||
"messages": [{"role": "user", "content": "inspect"}],
|
|
||||||
},
|
|
||||||
)
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert '"model": "chat-light"' in response.text
|
|
||||||
assert "ChatGPT-5.6:Luna" not in response.text
|
|
||||||
assert '"tool_calls"' in response.text
|
|
||||||
assert '"name": "read_file"' in response.text
|
|
||||||
assert "data: [DONE]" in response.text
|
|
||||||
|
|
||||||
|
|
||||||
def test_openwebui_background_task_bypasses_work_loop(settings, identity_jwt) -> None:
|
|
||||||
provider = FakeModelProvider()
|
|
||||||
app = create_app(settings, store=RuntimeStore(settings.database_url), provider=provider, tools=FakeTools())
|
|
||||||
with TestClient(app) as client:
|
|
||||||
response = client.post(
|
response = client.post(
|
||||||
"/v1/chat/completions",
|
"/v1/chat/completions",
|
||||||
headers=headers(settings, identity_jwt, "background-task"),
|
headers=headers(settings, identity_jwt, "background-task"),
|
||||||
json={
|
json={
|
||||||
"model": "work-medium",
|
"model": "terra",
|
||||||
"messages": [{"role": "user", "content": "Generate tags"}],
|
"messages": [{"role": "user", "content": "Generate tags"}],
|
||||||
"metadata": {"task": "tags_generation"},
|
"metadata": {"task": "tags_generation"},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.json()["model"] == "work-medium"
|
assert response.json()["model"] == "terra"
|
||||||
assert provider.completed == []
|
assert provider.completed == []
|
||||||
assert provider.forwarded[-1]["model"] == "ChatGPT-5.6:Terra"
|
assert provider.forwarded[-1]["model"] == "ChatGPT-5.6:Terra"
|
||||||
|
|
||||||
|
|
||||||
def test_work_stream_never_emits_delta_tool_calls(settings, identity_jwt) -> None:
|
def test_agent_stream_never_emits_delta_tool_calls(settings, identity_jwt) -> None:
|
||||||
app = create_app(
|
with TestClient(_app(settings)) as client:
|
||||||
settings,
|
|
||||||
store=RuntimeStore(settings.database_url),
|
|
||||||
provider=FakeModelProvider(),
|
|
||||||
tools=FakeTools(),
|
|
||||||
)
|
|
||||||
with TestClient(app) as client:
|
|
||||||
response = client.post(
|
response = client.post(
|
||||||
"/v1/chat/completions",
|
"/v1/chat/completions",
|
||||||
headers=headers(settings, identity_jwt, "stream-chat"),
|
headers=headers(settings, identity_jwt, "stream-chat"),
|
||||||
json={
|
json={
|
||||||
"model": "work-medium",
|
"model": "terra",
|
||||||
"stream": True,
|
"stream": True,
|
||||||
"messages": [{"role": "user", "content": "do it"}],
|
"messages": [{"role": "user", "content": "do it"}],
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert "work complete" in response.text
|
assert "agent complete" in response.text
|
||||||
for line in response.text.splitlines():
|
for line in response.text.splitlines():
|
||||||
if line.startswith("data: {"):
|
if line.startswith("data: {"):
|
||||||
chunk = json.loads(line.removeprefix("data: "))
|
chunk = json.loads(line.removeprefix("data: "))
|
||||||
|
|||||||
@@ -3,18 +3,6 @@ from __future__ import annotations
|
|||||||
from agent_platform.store import RuntimeStore
|
from agent_platform.store import RuntimeStore
|
||||||
|
|
||||||
|
|
||||||
async def test_conversation_mode_upgrade_is_one_way(settings) -> None:
|
|
||||||
store = RuntimeStore(settings.database_url)
|
|
||||||
await store.initialize()
|
|
||||||
try:
|
|
||||||
assert await store.select_mode("u1", "c1", "chat") == "chat"
|
|
||||||
assert await store.select_mode("u1", "c1", "work") == "work"
|
|
||||||
assert await store.select_mode("u1", "c1", "chat") == "work"
|
|
||||||
assert await store.select_mode("u2", "c1", "chat") == "chat"
|
|
||||||
finally:
|
|
||||||
await store.close()
|
|
||||||
|
|
||||||
|
|
||||||
async def test_memory_and_events_are_user_scoped(settings) -> None:
|
async def test_memory_and_events_are_user_scoped(settings) -> None:
|
||||||
store = RuntimeStore(settings.database_url)
|
store = RuntimeStore(settings.database_url)
|
||||||
await store.initialize()
|
await store.initialize()
|
||||||
|
|||||||
Reference in New Issue
Block a user