feat: enhance third-party proxy billing integration with multiple usage paths and update migration guide
This commit is contained in:
parent
1124a2e371
commit
169332ab29
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue