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, record.proxy_call_id,
) )
final_amount: float = 0.0 final_amount: float = 0.0
if is_success and query_route.usage_jsonpath: usage_paths = list(query_route.usage_jsonpaths or [])
raw_amount = proxy.jsonpath_get(resp_json, query_route.usage_jsonpath) if not usage_paths and query_route.usage_jsonpath:
try: usage_paths = [query_route.usage_jsonpath]
final_amount = float(raw_amount) if raw_amount is not None else 0.0 if is_success:
except (TypeError, ValueError): final_amount = _resolve_final_amount(resp_json, query_route)
final_amount = 0.0
logger.debug( 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, record.proxy_call_id,
final_amount, final_amount,
usage_paths,
query_route.usage_jsonpath, query_route.usage_jsonpath,
) )
@ -391,6 +391,30 @@ def _try_parse_json(data: bytes) -> dict[str, Any] | None:
return 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( def _proxy_response(
data: dict[str, Any], data: dict[str, Any],
proxy_call_id: str | None, proxy_call_id: str | None,

View File

@ -56,6 +56,14 @@ class QueryRouteConfig(BaseModel):
"E.g. usage.thirdPartyConsumeMoney" "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): class ThirdPartyProviderConfig(BaseModel):

View File

@ -3,6 +3,7 @@
from __future__ import annotations from __future__ import annotations
from app.gateway.third_party_proxy.ledger import CallLedger 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 ( from app.gateway.third_party_proxy.proxy import (
_path_matches, _path_matches,
jsonpath_get, jsonpath_get,
@ -99,6 +100,7 @@ _PROVIDER_CFG = ThirdPartyProviderConfig(
success_values=["SUCCESS"], success_values=["SUCCESS"],
failure_values=["FAILED", "CANCELLED"], failure_values=["FAILED", "CANCELLED"],
usage_jsonpath="usage.thirdPartyConsumeMoney", 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"}) ledger.update_response(rec.proxy_call_id, {"result": "ok"})
found = ledger.get(rec.proxy_call_id) found = ledger.get(rec.proxy_call_id)
assert found.last_response == {"result": "ok"} 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 async task APIs such as RunningHub.
third_party_proxy: third_party_proxy:
enabled: false enabled: true
providers: providers:
runninghub: runninghub:
base_url: https://www.runninghub.cn base_url: https://www.runninghub.cn
@ -64,35 +64,52 @@ third_party_proxy:
api_key_header: Authorization api_key_header: Authorization
api_key_prefix: "Bearer " api_key_prefix: "Bearer "
timeout_seconds: 30.0 timeout_seconds: 30.0
frozen_amount: 10.0
frozen_type: 2 frozen_type: 2
submit_routes: submit_routes:
- path_pattern: "/openapi/v2/**" - path_pattern: "/openapi/v2/rhart-image/z-image/turbo-lora"
exclude_path_pattern: "/openapi/v2/query"
task_id_jsonpath: "taskId" task_id_jsonpath: "taskId"
# Optional per-model billing override examples: frozen_amount: 0.03
# frozen_amount: 10.0 frozen_type: 2
# frozen_type: 2 - path_pattern: "/openapi/v2/rhart-image-g-2/text-to-image"
task_id_jsonpath: "taskId"
# Example: model-specific reserve policy frozen_amount: 0.2
# - path_pattern: "/openapi/v2/rhart-image/z-image/turbo-lora" frozen_type: 2
# task_id_jsonpath: "taskId" - path_pattern: "/openapi/v2/rhart-image-g-2/image-to-image"
# frozen_amount: 10.0 task_id_jsonpath: "taskId"
# frozen_type: 2 frozen_amount: 0.2
# - path_pattern: "/openapi/v2/vidu/text-to-video-q3-turbo" frozen_type: 2
# task_id_jsonpath: "taskId" - path_pattern: "/openapi/v2/rhart-audio/text-to-audio/speech-2.8-turbo"
# frozen_amount: 50.0 task_id_jsonpath: "taskId"
# frozen_type: 2 frozen_amount: 1.85
# - path_pattern: "/openapi/v2/wan-2.7/image-edit" frozen_type: 2
# task_id_jsonpath: "taskId" - path_pattern: "/task/openapi/create"
# frozen_amount: 20.0 task_id_jsonpath: "data.taskId"
# frozen_type: 2 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: query_routes:
- path_pattern: "/openapi/v2/query" - path_pattern: "/openapi/v2/query"
request_task_id_jsonpath: "taskId" request_task_id_jsonpath: "taskId"
status_jsonpath: "status" status_jsonpath: "status"
success_values: ["SUCCESS"] success_values: ["SUCCESS"]
failure_values: ["FAILED", "CANCELLED"] 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 # Token Usage Tracking

View File

@ -35,7 +35,7 @@ The examples below assume Python + requests.
Add: Add:
- `load_skill_env()`: loads skill-local `.env` - `load_skill_env()`: loads skill-local `.env`
- `get_gateway_config()`: reads - `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`) - `RUNNINGHUB_PROXY_PROVIDER` (default `runninghub`)
### Step 2: Centralize proxy headers ### Step 2: Centralize proxy headers
@ -83,6 +83,25 @@ Recommended checks:
- submit fallback when `taskId` is missing - submit fallback when `taskId` is missing
- query loop timeout/failure handling - 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) ## 4. Proxy Config Migration (config.yaml)
Configure submit/query routes under `third_party_proxy.providers.<provider>`. 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. 5. Config extraction fields use shorthand dot-paths only.
6. Submit returns `taskId`, then query reaches `RUNNING/SUCCESS`. 6. Submit returns `taskId`, then query reaches `RUNNING/SUCCESS`.
7. Gateway logs show submit/query route hits and finalize flow. 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 ## 8. Reference Implementations