feat: enhance third-party proxy billing integration with multiple usage paths and update migration guide

This commit is contained in:
Titan 2026-04-28 12:15:13 +08:00
parent 1124a2e371
commit 169332ab29
5 changed files with 133 additions and 29 deletions

View File

@ -273,17 +273,17 @@ async def _handle_query(
record.proxy_call_id,
)
final_amount: float = 0.0
if is_success and query_route.usage_jsonpath:
raw_amount = proxy.jsonpath_get(resp_json, query_route.usage_jsonpath)
try:
final_amount = float(raw_amount) if raw_amount is not None else 0.0
except (TypeError, ValueError):
final_amount = 0.0
usage_paths = list(query_route.usage_jsonpaths or [])
if not usage_paths and query_route.usage_jsonpath:
usage_paths = [query_route.usage_jsonpath]
if is_success:
final_amount = _resolve_final_amount(resp_json, query_route)
logger.debug(
"[ThirdPartyProxy] finalize amount resolved: proxy_call_id=%s final_amount=%s usage_path=%s",
"[ThirdPartyProxy] finalize amount resolved: proxy_call_id=%s final_amount=%s usage_paths=%s legacy_path=%s",
record.proxy_call_id,
final_amount,
usage_paths,
query_route.usage_jsonpath,
)
@ -391,6 +391,30 @@ def _try_parse_json(data: bytes) -> dict[str, Any] | None:
return None
def _resolve_final_amount(resp_json: dict[str, Any], query_route) -> float:
"""Resolve final billing amount from configured usage paths.
Priority:
1) `usage_jsonpaths` (sum all valid numeric values)
2) legacy `usage_jsonpath` (single value)
"""
usage_paths = list(query_route.usage_jsonpaths or [])
if not usage_paths and query_route.usage_jsonpath:
usage_paths = [query_route.usage_jsonpath]
total = 0.0
for path in usage_paths:
raw = proxy.jsonpath_get(resp_json, path)
if raw is None:
continue
try:
total += float(raw)
except (TypeError, ValueError):
continue
return total
def _proxy_response(
data: dict[str, Any],
proxy_call_id: str | None,

View File

@ -56,6 +56,14 @@ class QueryRouteConfig(BaseModel):
"E.g. usage.thirdPartyConsumeMoney"
),
)
usage_jsonpaths: list[str] = Field(
default_factory=list,
description=(
"Optional list of dot-paths into the response body to extract monetary costs and sum them. "
"When set, values from all valid paths are added together. "
"Example: [\"usage.thirdPartyConsumeMoney\", \"usage.consumeMoney\"]"
),
)
class ThirdPartyProviderConfig(BaseModel):

View File

@ -3,6 +3,7 @@
from __future__ import annotations
from app.gateway.third_party_proxy.ledger import CallLedger
from app.gateway.routers.third_party import _resolve_final_amount
from app.gateway.third_party_proxy.proxy import (
_path_matches,
jsonpath_get,
@ -99,6 +100,7 @@ _PROVIDER_CFG = ThirdPartyProviderConfig(
success_values=["SUCCESS"],
failure_values=["FAILED", "CANCELLED"],
usage_jsonpath="usage.thirdPartyConsumeMoney",
usage_jsonpaths=["usage.thirdPartyConsumeMoney", "usage.consumeMoney"],
)
],
)
@ -190,3 +192,36 @@ class TestCallLedger:
ledger.update_response(rec.proxy_call_id, {"result": "ok"})
found = ledger.get(rec.proxy_call_id)
assert found.last_response == {"result": "ok"}
class TestResolveFinalAmount:
def test_sum_multiple_usage_paths(self):
route = QueryRouteConfig(
path_pattern="/openapi/v2/query",
request_task_id_jsonpath="taskId",
status_jsonpath="status",
success_values=["SUCCESS"],
failure_values=["FAILED"],
usage_jsonpaths=["usage.thirdPartyConsumeMoney", "usage.consumeMoney"],
)
resp_json = {
"usage": {
"thirdPartyConsumeMoney": None,
"consumeMoney": "0.099",
}
}
amount = _resolve_final_amount(resp_json, route)
assert amount == 0.099
def test_fallback_to_legacy_single_usage_path(self):
route = QueryRouteConfig(
path_pattern="/openapi/v2/query",
request_task_id_jsonpath="taskId",
status_jsonpath="status",
success_values=["SUCCESS"],
failure_values=["FAILED"],
usage_jsonpath="usage.thirdPartyConsumeMoney",
)
resp_json = {"usage": {"thirdPartyConsumeMoney": "1.5"}}
amount = _resolve_final_amount(resp_json, route)
assert amount == 1.5

View File

@ -56,7 +56,7 @@ billing:
# third-party async task APIs such as RunningHub.
third_party_proxy:
enabled: false
enabled: true
providers:
runninghub:
base_url: https://www.runninghub.cn
@ -64,35 +64,52 @@ third_party_proxy:
api_key_header: Authorization
api_key_prefix: "Bearer "
timeout_seconds: 30.0
frozen_amount: 10.0
frozen_type: 2
submit_routes:
- path_pattern: "/openapi/v2/**"
exclude_path_pattern: "/openapi/v2/query"
- path_pattern: "/openapi/v2/rhart-image/z-image/turbo-lora"
task_id_jsonpath: "taskId"
# Optional per-model billing override examples:
# frozen_amount: 10.0
# frozen_type: 2
# Example: model-specific reserve policy
# - path_pattern: "/openapi/v2/rhart-image/z-image/turbo-lora"
# task_id_jsonpath: "taskId"
# frozen_amount: 10.0
# frozen_type: 2
# - path_pattern: "/openapi/v2/vidu/text-to-video-q3-turbo"
# task_id_jsonpath: "taskId"
# frozen_amount: 50.0
# frozen_type: 2
# - path_pattern: "/openapi/v2/wan-2.7/image-edit"
# task_id_jsonpath: "taskId"
# frozen_amount: 20.0
# frozen_type: 2
frozen_amount: 0.03
frozen_type: 2
- path_pattern: "/openapi/v2/rhart-image-g-2/text-to-image"
task_id_jsonpath: "taskId"
frozen_amount: 0.2
frozen_type: 2
- path_pattern: "/openapi/v2/rhart-image-g-2/image-to-image"
task_id_jsonpath: "taskId"
frozen_amount: 0.2
frozen_type: 2
- path_pattern: "/openapi/v2/rhart-audio/text-to-audio/speech-2.8-turbo"
task_id_jsonpath: "taskId"
frozen_amount: 1.85
frozen_type: 2
- path_pattern: "/task/openapi/create"
task_id_jsonpath: "data.taskId"
frozen_amount: 2.0
frozen_type: 2
- path_pattern: "/openapi/v2/vidu/text-to-video-q3-turbo"
task_id_jsonpath: "taskId"
frozen_amount: 11.2
frozen_type: 2
query_routes:
- path_pattern: "/openapi/v2/query"
request_task_id_jsonpath: "taskId"
status_jsonpath: "status"
success_values: ["SUCCESS"]
failure_values: ["FAILED", "CANCELLED"]
usage_jsonpath: "usage.thirdPartyConsumeMoney"
usage_jsonpaths: ["usage.thirdPartyConsumeMoney", "usage.consumeMoney"]
dashscope:
base_url: https://dashscope.aliyuncs.com
api_key_env: DASHSCOPE_API_KEY
api_key_header: Authorization
api_key_prefix: "Bearer "
timeout_seconds: 60.0
submit_routes:
- path_pattern: "/compatible-mode/v1/chat/completions"
task_id_jsonpath: "id"
frozen_type: 1
query_routes: []
# ============================================================================
# Token Usage Tracking

View File

@ -35,7 +35,7 @@ The examples below assume Python + requests.
Add:
- `load_skill_env()`: loads skill-local `.env`
- `get_gateway_config()`: reads
- `DEER_FLOW_GATEWAY_URL` (default `http://host.docker.internal:8101`)
- `DEER_FLOW_GATEWAY_URL` (default `http://host.docker.internal:8001`)
- `RUNNINGHUB_PROXY_PROVIDER` (default `runninghub`)
### Step 2: Centralize proxy headers
@ -83,6 +83,25 @@ Recommended checks:
- submit fallback when `taskId` is missing
- query loop timeout/failure handling
### Step 7: Clean API key instructions from skill.md
After migrating a skill to gateway proxy, remove any user-facing instructions in `skill.md` that ask users to configure third-party provider keys (for example, `RUNNINGHUB_API_KEY`).
What to remove from `skill.md`:
- "Set `RUNNINGHUB_API_KEY` in .env"
- "Create an API key on provider platform"
- Any step that tells users to pass `Authorization: Bearer ...`
What to keep/add in `skill.md`:
- Mention that third-party credentials are handled by gateway config
- Keep only skill runtime inputs (prompt, output path, optional style/duration)
- Optionally mention gateway-related vars if needed by local debugging:
- `DEER_FLOW_GATEWAY_URL`
- `RUNNINGHUB_PROXY_PROVIDER`
Suggested replacement sentence:
- "This skill uses DeerFlow Gateway third-party proxy. Provider credentials are configured centrally in gateway and are not required in this skill's local `.env`."
## 4. Proxy Config Migration (config.yaml)
Configure submit/query routes under `third_party_proxy.providers.<provider>`.
@ -193,6 +212,7 @@ For Docker-based sandbox execution, use:
5. Config extraction fields use shorthand dot-paths only.
6. Submit returns `taskId`, then query reaches `RUNNING/SUCCESS`.
7. Gateway logs show submit/query route hits and finalize flow.
8. `skill.md` no longer contains instructions to configure third-party API keys.
## 8. Reference Implementations