Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion api/ops/chat_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,9 @@ def handle_ops_chat_message(
clarification = clarify_if_fallback(body.message, body.session_id, transcript, slots)

if clarification.needs_clarification:
run = store.create_run(query=body.message, route="clarify", session_id=body.session_id)
# clarify 为 API 层路由;落库用 fast(ops_runs_route_check 未含 clarify)。
# 语义由 clarify.asked 事件承载;迁移 ops_desk_p1_clarify_route.sql 后可改为 route="clarify"。
run = store.create_run(query=body.message, route="fast", session_id=body.session_id)
run_id = str(run["id"])
store.append_event(
run_id,
Expand Down
19 changes: 10 additions & 9 deletions api/ops/llm/model_catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,24 +47,25 @@ def bailian_model_ids() -> list[str]:


def resolve_bailian_model_chain(primary: str | None) -> list[str]:
"""从 primary 起向下 fallback;未知 id 则 primary 优先再完整链
"""从 primary 起构建 fallback;未知 id 则 primary 优先再补生产模型

若 primary 为 test_only(如 kimi 无额度测试项),配额 fallback 时跳过链中
其余 test_only(如 ZHIPU),直达 deepseek-v4-pro 等生产模型。

生产模型 primary 耗尽额度时,回退到其余生产模型(避免链尾模型无后继)。
"""
chain_ids = bailian_model_ids()
test_only = bailian_test_only_model_ids()
prod_ids = [mid for mid in chain_ids if mid not in test_only]
if not primary or not primary.strip():
return list(chain_ids)
primary = primary.strip()
if primary not in chain_ids:
return [primary, *[mid for mid in chain_ids if mid not in test_only or mid == primary]]
idx = chain_ids.index(primary)
tail = chain_ids[idx:]
if tail and tail[0] in test_only:
prod_tail = [mid for mid in tail[1:] if mid not in test_only]
return [tail[0], *prod_tail]
return tail
if primary in test_only:
prod_tail = [mid for mid in prod_ids]
return [primary, *prod_tail]
if primary in prod_ids:
return [primary, *[mid for mid in prod_ids if mid != primary]]
return [primary, *prod_ids]


def get_chat_models_payload() -> dict[str, Any]:
Expand Down
21 changes: 16 additions & 5 deletions api/ops/llm/providers/openai_compatible.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,25 +11,36 @@
from api.ops.llm.errors import OpsLlmRequestError
from api.ops.llm.types import LlmCompletionResult, LlmUsage

_BAILIAN_QUOTA_MARKER = "AllocationQuota.FreeTierOnly"
_BAILIAN_QUOTA_MARKERS = (
"AllocationQuota.FreeTierOnly",
"free quota has been exhausted",
"free tier only",
)
_DEFAULT_MAX_ATTEMPTS = 3


def _text_has_bailian_quota_marker(text: str) -> bool:
lowered = text.lower()
return any(marker.lower() in lowered for marker in _BAILIAN_QUOTA_MARKERS)


def is_bailian_quota_error(response: requests.Response) -> bool:
"""百炼无额度:403 且 body 含 AllocationQuota.FreeTierOnly。"""
"""百炼无额度:403 且 body 含已知配额耗尽标记。"""
if response.status_code != 403:
return False
if _text_has_bailian_quota_marker(response.text):
return True
try:
data = response.json()
except ValueError:
return _BAILIAN_QUOTA_MARKER in response.text
return False
err = data.get("error")
if isinstance(err, dict):
code = str(err.get("code") or err.get("type") or "")
message = str(err.get("message") or "")
if _BAILIAN_QUOTA_MARKER in code or _BAILIAN_QUOTA_MARKER in message:
if _text_has_bailian_quota_marker(code) or _text_has_bailian_quota_marker(message):
return True
return _BAILIAN_QUOTA_MARKER in str(data)
return _text_has_bailian_quota_marker(str(data))


def _is_retryable_status(status_code: int) -> bool:
Expand Down
14 changes: 14 additions & 0 deletions api/ops/runs.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,20 @@ def get_events(
return {"run_id": run_id, "after_seq": after_seq, "events": events}


@router.get("/{run_id}/artifacts")
def get_artifacts(
run_id: str = Path(...),
store: OpsRunStore = Depends(_store),
_: None = Depends(require_ops_secret),
) -> dict[str, Any]:
"""返回 run 关联的 ops_run_artifacts;无记录时 artifacts=[]。"""
run = store.get_run(run_id)
if not run:
raise HTTPException(status_code=404, detail={"code": "RUN_NOT_FOUND"})
artifacts = store.list_artifacts(run_id)
return {"run_id": run_id, "artifacts": artifacts}


@router.post("/{run_id}/retry")
def retry_run(
run_id: str = Path(...),
Expand Down
22 changes: 15 additions & 7 deletions api/ops/store/runs.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
import time
from typing import Any

from postgrest.exceptions import APIError

from api.ops.events_schema import SCHEMA_VERSION
from api.rag_env import supabase_client, supabase_execute_with_retry

Expand Down Expand Up @@ -273,13 +275,19 @@ def _once() -> dict[str, Any]:

def list_artifacts(self, run_id: str) -> list[dict[str, Any]]:
def _once() -> list[dict[str, Any]]:
res = (
self.client.table("ops_run_artifacts")
.select("*")
.eq("run_id", run_id)
.order("created_at", desc=True)
.execute()
)
try:
res = (
self.client.table("ops_run_artifacts")
.select("*")
.eq("run_id", run_id)
.order("created_at", desc=True)
.execute()
)
except APIError as exc:
# 迁移 ops_desk_p1_artifacts.sql 未应用时,读路径静默返回空列表
if getattr(exc, "code", None) == "PGRST205":
return []
raise
return res.data if isinstance(res.data, list) else []

return supabase_execute_with_retry(_once)
Expand Down
1 change: 1 addition & 0 deletions docs/_tech_graph/_manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,7 @@
"ops_issues",
"ops_pull_requests",
"ops_repos",
"ops_run_artifacts",
"ops_run_checkpoints",
"ops_run_events",
"ops_runs",
Expand Down
7 changes: 7 additions & 0 deletions supabase/sql/ops_desk_p1_clarify_route.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
-- Ops Chat P1-3 · clarify 路由扩展
-- 用途:ops_runs.route CHECK 增加 clarify(FALLBACK 澄清短路)
-- 依赖:ops_desk_s2_session_00_route.sql 已应用

ALTER TABLE public.ops_runs DROP CONSTRAINT IF EXISTS ops_runs_route_check;
ALTER TABLE public.ops_runs ADD CONSTRAINT ops_runs_route_check
CHECK (route IN ('fast', 'deep', 'react', 'session_00', 'clarify'));
5 changes: 5 additions & 0 deletions supabase/sql/ops_desk_p1_clarify_route_rollback.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
-- Ops Chat P1-3 · clarify 路由扩展回滚

ALTER TABLE public.ops_runs DROP CONSTRAINT IF EXISTS ops_runs_route_check;
ALTER TABLE public.ops_runs ADD CONSTRAINT ops_runs_route_check
CHECK (route IN ('fast', 'deep', 'react', 'session_00'));
25 changes: 25 additions & 0 deletions tests/ops_desk/test_llm_usage_metrics.py
Original file line number Diff line number Diff line change
Expand Up @@ -970,6 +970,13 @@ def test_resolve_bailian_model_chain_from_primary() -> None:
assert "kimi/kimi-k2.7-code" not in chain


def test_resolve_bailian_model_chain_qwen_fallbacks_to_prod_models() -> None:
from api.ops.llm.model_catalog import resolve_bailian_model_chain

chain = resolve_bailian_model_chain("qwen3.7-plus")
assert chain == ["qwen3.7-plus", "deepseek-v4-pro", "deepseek-v4-flash"]


def test_resolve_bailian_model_chain_skips_test_only_after_kimi() -> None:
from api.ops.llm.model_catalog import resolve_bailian_model_chain

Expand All @@ -992,6 +999,14 @@ class QuotaResponse:
def json(self) -> dict[str, Any]:
return {"error": {"code": "AllocationQuota.FreeTierOnly", "message": "no quota"}}

class FreeQuotaExhaustedResponse:
status_code = 403
ok = False
text = '{"error":{"message":"The free quota has been exhausted."}}'

def json(self) -> dict[str, Any]:
return {"error": {"message": "The free quota has been exhausted."}}

class OkResponse:
status_code = 200
ok = True
Expand All @@ -1010,6 +1025,8 @@ def fake_post(*args: Any, **kwargs: Any) -> Any:
model = kwargs["json"]["model"]
if model in ("kimi/kimi-k2.7-code",):
return QuotaResponse()
if model == "qwen3.7-plus":
return FreeQuotaExhaustedResponse()
return OkResponse(model)

monkeypatch.setattr(
Expand All @@ -1026,6 +1043,14 @@ def fake_post(*args: Any, **kwargs: Any) -> Any:
assert result.content == "ok:deepseek-v4-pro"
assert result.usage.model == "deepseek-v4-pro"

result2 = provider.complete(
[{"role": "user", "content": "hi"}],
model="qwen3.7-plus",
step="analyze",
)
assert result2.content == "ok:deepseek-v4-pro"
assert result2.usage.model == "deepseek-v4-pro"


def test_chat_models_endpoint_bailian(monkeypatch) -> None:
monkeypatch.setenv("OPS_LLM_PROVIDER", "bailian")
Expand Down
31 changes: 31 additions & 0 deletions tests/ops_desk/test_orchestrator_p1.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ class FakeStore(OpsRunStore):
def __init__(self) -> None: # type: ignore[override]
self.runs: dict[str, dict[str, Any]] = {}
self.events: dict[str, list[dict[str, Any]]] = {}
self.artifacts: dict[str, list[dict[str, Any]]] = {}
self._counter = 0

def create_run(
Expand Down Expand Up @@ -149,6 +150,9 @@ def validate_retry_token(self, run_id: str, retry_token: str) -> bool:
run = self.get_run(run_id)
return bool(run) and run.get("retry_token") == retry_token

def list_artifacts(self, run_id: str) -> list[dict[str, Any]]:
return list(self.artifacts.get(run_id, []))


@pytest.fixture
def client(monkeypatch: pytest.MonkeyPatch) -> TestClient:
Expand Down Expand Up @@ -280,3 +284,30 @@ def test_stream_not_implemented(client: TestClient) -> None:
resp = client.get("/api/py/ops/runs/run-1/stream", headers={"x-ops-secret": "test"})
assert resp.status_code == 404
assert resp.json()["detail"]["code"] == "SSE_NOT_IMPLEMENTED"


def test_get_run_artifacts_empty(client: TestClient) -> None:
resp = client.post(
"/api/py/ops/chat/messages",
json={"message": "#545 适合我吗"},
headers={"x-ops-secret": "test"},
)
run_id = resp.json()["run_id"]

art_resp = client.get(
f"/api/py/ops/runs/{run_id}/artifacts",
headers={"x-ops-secret": "test"},
)
assert art_resp.status_code == 200
body = art_resp.json()
assert body["run_id"] == run_id
assert body["artifacts"] == []


def test_get_run_artifacts_not_found(client: TestClient) -> None:
resp = client.get(
"/api/py/ops/runs/run-missing/artifacts",
headers={"x-ops-secret": "test"},
)
assert resp.status_code == 404
assert resp.json()["detail"]["code"] == "RUN_NOT_FOUND"