diff --git a/backend/app/gateway/routers/third_party.py b/backend/app/gateway/routers/third_party.py index 3e38f2b1..fbc14f8a 100644 --- a/backend/app/gateway/routers/third_party.py +++ b/backend/app/gateway/routers/third_party.py @@ -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, diff --git a/backend/packages/harness/deerflow/config/third_party_proxy_config.py b/backend/packages/harness/deerflow/config/third_party_proxy_config.py index 55c349f9..81f9d3a5 100644 --- a/backend/packages/harness/deerflow/config/third_party_proxy_config.py +++ b/backend/packages/harness/deerflow/config/third_party_proxy_config.py @@ -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): diff --git a/backend/tests/test_third_party_proxy.py b/backend/tests/test_third_party_proxy.py index 35d86bc5..b0da77fd 100644 --- a/backend/tests/test_third_party_proxy.py +++ b/backend/tests/test_third_party_proxy.py @@ -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 diff --git a/config.example.yaml b/config.example.yaml index 308236f9..f05cc496 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -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 diff --git a/docs/SKILL_PROXY_MIGRATION_GUIDE.md b/docs/SKILL_PROXY_MIGRATION_GUIDE.md index 9cedd20c..3901ec16 100644 --- a/docs/SKILL_PROXY_MIGRATION_GUIDE.md +++ b/docs/SKILL_PROXY_MIGRATION_GUIDE.md @@ -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.`. @@ -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