diff --git a/comfy_api_nodes/nodes_anthropic.py b/comfy_api_nodes/nodes_anthropic.py index 011c3f0cf..76c611b93 100644 --- a/comfy_api_nodes/nodes_anthropic.py +++ b/comfy_api_nodes/nodes_anthropic.py @@ -106,49 +106,6 @@ def _claude_model_inputs(model_label: str): return inputs -def _model_price_per_million(model: str) -> tuple[float, float] | None: - """Return (input_per_1M, output_per_1M) USD for a Claude model, or None if unknown.""" - if "fable-5" in model: - return 14.30, 71.50 - if "opus-5" in model or "opus-4-8" in model: - return 7.15, 35.75 - if "sonnet-5" in model: - return 2.86, 14.30 - if "opus-4-7" in model or "opus-4-6" in model or "opus-4-5" in model: - return 5.0, 25.0 - if "sonnet-4" in model: - return 3.0, 15.0 - if "haiku-4-5" in model: - return 1.0, 5.0 - return None - - -def calculate_tokens_price(response: AnthropicMessagesResponse) -> float | None: - """Compute approximate USD price from response usage. Server-side billing is authoritative.""" - if not response.usage or not response.model: - return None - rates = _model_price_per_million(response.model) - if rates is None: - return None - input_rate, output_rate = rates - input_tokens = response.usage.input_tokens or 0 - output_tokens = response.usage.output_tokens or 0 - cache_read = response.usage.cache_read_input_tokens or 0 - cache_5m = 0 - cache_1h = 0 - if response.usage.cache_creation: - cache_5m = response.usage.cache_creation.ephemeral_5m_input_tokens or 0 - cache_1h = response.usage.cache_creation.ephemeral_1h_input_tokens or 0 - total = ( - input_tokens * input_rate - + output_tokens * output_rate - + cache_read * input_rate * 0.1 - + cache_5m * input_rate * 1.25 - + cache_1h * input_rate * 2.0 - ) - return total / 1_000_000.0 - - def _get_text_from_response(response: AnthropicMessagesResponse) -> str: if not response.content: return "" @@ -344,7 +301,6 @@ class ClaudeNode(IO.ComfyNode): thinking=thinking_cfg, output_config=output_cfg, ), - price_extractor=calculate_tokens_price, ) if response.stop_reason == "refusal": raise ValueError( diff --git a/comfy_api_nodes/nodes_bytedance_llm.py b/comfy_api_nodes/nodes_bytedance_llm.py index cb41defa0..0403e0c1f 100644 --- a/comfy_api_nodes/nodes_bytedance_llm.py +++ b/comfy_api_nodes/nodes_bytedance_llm.py @@ -34,13 +34,6 @@ SEED_MODELS: dict[str, str] = { "Seed 2.0 Mini": "seed-2-0-mini-260215", } -# USD per 1M tokens: (input, cache_hit_input, output) -_SEED_PRICES_PER_MILLION: dict[str, tuple[float, float, float]] = { - "seed-2-0-pro-260328": (0.50, 0.10, 3.00), - "seed-2-0-lite-260228": (0.25, 0.05, 2.00), - "seed-2-0-mini-260215": (0.10, 0.02, 0.40), -} - def _seed_model_inputs(max_images: int = SEED_MAX_IMAGES, max_videos: int = SEED_MAX_VIDEOS): return [ @@ -74,24 +67,6 @@ def _seed_model_inputs(max_images: int = SEED_MAX_IMAGES, max_videos: int = SEED ] -def _calculate_price(model_id: str, response: BytePlusResponseObject) -> float | None: - """Compute approximate USD price from response usage.""" - if not response.usage: - return None - rates = _SEED_PRICES_PER_MILLION.get(model_id) - if rates is None: - return None - input_rate, cache_hit_rate, output_rate = rates - input_tokens = response.usage.input_tokens or 0 - output_tokens = response.usage.output_tokens or 0 - cached = 0 - if response.usage.input_tokens_details: - cached = response.usage.input_tokens_details.cached_tokens or 0 - fresh_input = max(0, input_tokens - cached) - total = fresh_input * input_rate + cached * cache_hit_rate + output_tokens * output_rate - return total / 1_000_000.0 - - def _get_text_from_response(response: BytePlusResponseObject) -> str: """Extract concatenated text from all assistant message output_text blocks.""" if not response.output: @@ -251,7 +226,6 @@ class ByteDanceSeedNode(IO.ComfyNode): store=False, stream=False, ), - price_extractor=lambda r: _calculate_price(model_id, r), ) if response.error: raise ValueError(f"Seed API error ({response.error.code}): {response.error.message}") diff --git a/comfy_api_nodes/nodes_gemini.py b/comfy_api_nodes/nodes_gemini.py index 47d028c6c..fd9ff04a8 100644 --- a/comfy_api_nodes/nodes_gemini.py +++ b/comfy_api_nodes/nodes_gemini.py @@ -35,7 +35,6 @@ from comfy_api_nodes.apis.gemini import ( GeminiSystemInstructionContent, GeminiTextPart, GeminiThinkingConfig, - Modality, ) from comfy_api_nodes.util import ( ApiEndpoint, @@ -238,60 +237,6 @@ async def get_image_from_response(response: GeminiGenerateContentResponse, thoug return torch.cat(image_tensors, dim=0) -def calculate_tokens_price(response: GeminiGenerateContentResponse) -> float | None: - if not response.modelVersion: - return None - # Define prices (Cost per 1,000,000 tokens), see https://cloud.google.com/vertex-ai/generative-ai/pricing - if response.modelVersion == "gemini-2.5-pro": - input_tokens_price = 1.25 - output_text_tokens_price = 10.0 - output_image_tokens_price = 0.0 - elif response.modelVersion == "gemini-2.5-flash": - input_tokens_price = 0.30 - output_text_tokens_price = 2.50 - output_image_tokens_price = 0.0 - elif response.modelVersion == "gemini-2.5-flash-image": - input_tokens_price = 0.30 - output_text_tokens_price = 2.50 - output_image_tokens_price = 30.0 - elif response.modelVersion in ("gemini-3-pro-preview", "gemini-3.1-pro-preview"): - input_tokens_price = 2 - output_text_tokens_price = 12.0 - output_image_tokens_price = 0.0 - elif response.modelVersion in ("gemini-3.1-flash-lite-preview", "gemini-3.1-flash-lite"): - input_tokens_price = 0.25 - output_text_tokens_price = 1.50 - output_image_tokens_price = 0.0 - elif response.modelVersion == "gemini-3.5-flash": - input_tokens_price = 1.50 - output_text_tokens_price = 9.0 - output_image_tokens_price = 0.0 - elif response.modelVersion in ("gemini-3-pro-image-preview", "gemini-3-pro-image"): - input_tokens_price = 2 - output_text_tokens_price = 12.0 - output_image_tokens_price = 120.0 - elif response.modelVersion in ("gemini-3.1-flash-image-preview", "gemini-3.1-flash-image"): - input_tokens_price = 0.5 - output_text_tokens_price = 3.0 - output_image_tokens_price = 60.0 - elif response.modelVersion == "gemini-3.1-flash-lite-image": - input_tokens_price = 0.25 - output_text_tokens_price = 1.50 - output_image_tokens_price = 30.0 - else: - return None - final_price = response.usageMetadata.promptTokenCount * input_tokens_price - if response.usageMetadata.candidatesTokensDetails: - for i in response.usageMetadata.candidatesTokensDetails: - if i.modality == Modality.IMAGE: - final_price += output_image_tokens_price * i.tokenCount # for Nano Banana models - else: - final_price += output_text_tokens_price * i.tokenCount - if response.usageMetadata.thoughtsTokenCount: - final_price += output_text_tokens_price * response.usageMetadata.thoughtsTokenCount - return final_price / 1_000_000.0 - - def get_text_from_interaction(interaction: GeminiInteraction) -> str: """Extract and concatenate all model output text from an Interactions API response.""" texts = [] @@ -326,24 +271,6 @@ async def get_video_from_interaction( ) -def calculate_interaction_tokens_price(interaction: GeminiInteraction) -> float | None: - if interaction.usage is None: - return None - input_tokens_price = 1.5 - output_tokens_prices = {"text": 9.0, "video": 17.5} - thoughts_tokens_price = 9.0 - final_price = 0.0 - for i in interaction.usage.input_tokens_by_modality or []: - if i.tokens: - final_price += input_tokens_price * i.tokens - for i in interaction.usage.output_tokens_by_modality or []: - if i.tokens and i.modality in output_tokens_prices: - final_price += output_tokens_prices[i.modality] * i.tokens - if interaction.usage.total_thought_tokens: - final_price += thoughts_tokens_price * interaction.usage.total_thought_tokens - return final_price / 1_000_000.0 - - def create_video_parts(video_input: Input.Video) -> list[GeminiPart]: """Convert a single video input to Gemini API compatible parts (inline MP4/H.264).""" base_64_string = video_to_base64_string( @@ -657,7 +584,6 @@ class GeminiNode(IO.ComfyNode): systemInstruction=gemini_system_prompt, ), response_model=GeminiGenerateContentResponse, - price_extractor=calculate_tokens_price, ) output_text = get_text_from_response(response) @@ -872,7 +798,6 @@ class GeminiNodeV2(IO.ComfyNode): systemInstruction=gemini_system_prompt, ), response_model=GeminiGenerateContentResponse, - price_extractor=calculate_tokens_price, ) output_text = get_text_from_response(response) @@ -1085,7 +1010,6 @@ class GeminiImage(IO.ComfyNode): systemInstruction=gemini_system_prompt, ), response_model=GeminiGenerateContentResponse, - price_extractor=calculate_tokens_price, ) return IO.NodeOutput(await get_image_from_response(response), get_text_from_response(response)) @@ -1225,7 +1149,6 @@ class GeminiImage2(IO.ComfyNode): systemInstruction=gemini_system_prompt, ), response_model=GeminiGenerateContentResponse, - price_extractor=calculate_tokens_price, ) return IO.NodeOutput(await get_image_from_response(response), get_text_from_response(response)) @@ -1385,7 +1308,6 @@ class GeminiNanoBanana2(IO.ComfyNode): systemInstruction=gemini_system_prompt, ), response_model=GeminiGenerateContentResponse, - price_extractor=calculate_tokens_price, ) return IO.NodeOutput( await get_image_from_response(response), @@ -1610,7 +1532,6 @@ class GeminiNanoBanana2V2(IO.ComfyNode): systemInstruction=gemini_system_prompt, ), response_model=GeminiGenerateContentResponse, - price_extractor=calculate_tokens_price, ) return IO.NodeOutput( await get_image_from_response(response), @@ -1762,7 +1683,6 @@ class GeminiVideoOmni(IO.ComfyNode): ), ), response_model=GeminiInteraction, - price_extractor=calculate_interaction_tokens_price, ) if interaction.status != "completed": model_message = get_text_from_interaction(interaction).strip() diff --git a/comfy_api_nodes/nodes_grok.py b/comfy_api_nodes/nodes_grok.py index dc484536e..a95b35917 100644 --- a/comfy_api_nodes/nodes_grok.py +++ b/comfy_api_nodes/nodes_grok.py @@ -155,7 +155,6 @@ class GrokImageNode(IO.ComfyNode): resolution=resolution.lower(), ), response_model=ImageGenerationResponse, - price_extractor=_extract_grok_price, ) if len(response.data) == 1: return IO.NodeOutput(await download_url_to_image_tensor(response.data[0].url)) @@ -351,7 +350,6 @@ class GrokImageEditNode(IO.ComfyNode): aspect_ratio=None if aspect_ratio == "auto" else aspect_ratio, ), response_model=ImageGenerationResponse, - price_extractor=_extract_grok_price, ) if len(response.data) == 1: return IO.NodeOutput(await download_url_to_image_tensor(response.data[0].url)) @@ -488,7 +486,6 @@ class GrokImageEditNodeV2(IO.ComfyNode): aspect_ratio=None if aspect_ratio == "auto" else aspect_ratio, ), response_model=ImageGenerationResponse, - price_extractor=_extract_grok_price, ) if len(response.data) == 1: return IO.NodeOutput(await download_url_to_image_tensor(response.data[0].url)) diff --git a/comfy_api_nodes/nodes_openai.py b/comfy_api_nodes/nodes_openai.py index de2c94353..e73319e84 100644 --- a/comfy_api_nodes/nodes_openai.py +++ b/comfy_api_nodes/nodes_openai.py @@ -364,19 +364,6 @@ class OpenAIDalle3(IO.ComfyNode): return IO.NodeOutput(await validate_and_cast_response(response)) -def calculate_tokens_price_image_1(response: OpenAIImageGenerationResponse) -> float | None: - # https://platform.openai.com/docs/pricing - return ((response.usage.input_tokens * 10.0) + (response.usage.output_tokens * 40.0)) / 1_000_000.0 - - -def calculate_tokens_price_image_1_5(response: OpenAIImageGenerationResponse) -> float | None: - return ((response.usage.input_tokens * 8.0) + (response.usage.output_tokens * 32.0)) / 1_000_000.0 - - -def calculate_tokens_price_image_2_0(response: OpenAIImageGenerationResponse) -> float | None: - return ((response.usage.input_tokens * 8.0) + (response.usage.output_tokens * 30.0)) / 1_000_000.0 - - class OpenAIGPTImage1(IO.ComfyNode): @classmethod @@ -570,15 +557,10 @@ class OpenAIGPTImage1(IO.ComfyNode): if size not in ("auto", "1024x1024", "1024x1536", "1536x1024"): raise ValueError(f"Resolution {size} is only supported by GPT Image 2 model") - if model == "gpt-image-1": - price_extractor = calculate_tokens_price_image_1 - elif model == "gpt-image-1.5": - price_extractor = calculate_tokens_price_image_1_5 - elif model == "gpt-image-2": - price_extractor = calculate_tokens_price_image_2_0 + if model == "gpt-image-2": if background == "transparent": raise ValueError("Transparent background is not supported for GPT Image 2 model") - else: + elif model not in ("gpt-image-1", "gpt-image-1.5"): raise ValueError(f"Unknown model: {model}") if image is not None: @@ -633,7 +615,6 @@ class OpenAIGPTImage1(IO.ComfyNode): ), content_type="multipart/form-data", files=files, - price_extractor=price_extractor, ) else: response = await sync_op( @@ -650,7 +631,6 @@ class OpenAIGPTImage1(IO.ComfyNode): size=size, moderation="low", ), - price_extractor=price_extractor, ) return IO.NodeOutput(await validate_and_cast_response(response)) @@ -879,13 +859,7 @@ class OpenAIGPTImageNodeV2(IO.ComfyNode): ) size = f"{custom_width}x{custom_height}" - if model_id == "gpt-image-1": - price_extractor = calculate_tokens_price_image_1 - elif model_id == "gpt-image-1.5": - price_extractor = calculate_tokens_price_image_1_5 - elif model_id == "gpt-image-2": - price_extractor = calculate_tokens_price_image_2_0 - else: + if model_id not in ("gpt-image-1", "gpt-image-1.5", "gpt-image-2"): raise ValueError(f"Unknown model: {model_id}") if image_tensors: @@ -944,7 +918,6 @@ class OpenAIGPTImageNodeV2(IO.ComfyNode): ), content_type="multipart/form-data", files=files, - price_extractor=price_extractor, ) else: response = await sync_op( @@ -960,7 +933,6 @@ class OpenAIGPTImageNodeV2(IO.ComfyNode): size=size, moderation="low", ), - price_extractor=price_extractor, ) return IO.NodeOutput(await validate_and_cast_response(response)) diff --git a/comfy_api_nodes/nodes_openrouter.py b/comfy_api_nodes/nodes_openrouter.py index e9d6290c2..ee93a1228 100644 --- a/comfy_api_nodes/nodes_openrouter.py +++ b/comfy_api_nodes/nodes_openrouter.py @@ -159,12 +159,6 @@ def _build_model_options() -> list[IO.DynamicCombo.Option]: return [IO.DynamicCombo.Option(spec.slug, _inputs_for_model(spec)) for spec in MODELS] -def _calculate_price(response: OpenRouterChatResponse) -> float | None: - if response.usage and response.usage.cost is not None: - return float(response.usage.cost) * 1.43 - return None - - def _price_badge_jsonata() -> str: rates_pairs = [] for spec in MODELS: @@ -372,7 +366,6 @@ class OpenRouterLLMNode(IO.ComfyNode): ApiEndpoint(path=OPENROUTER_CHAT_ENDPOINT, method="POST"), response_model=OpenRouterChatResponse, data=request, - price_extractor=_calculate_price, ) return IO.NodeOutput(_extract_text(response)) diff --git a/comfy_api_nodes/nodes_reve.py b/comfy_api_nodes/nodes_reve.py index 177349a8b..9120c7195 100644 --- a/comfy_api_nodes/nodes_reve.py +++ b/comfy_api_nodes/nodes_reve.py @@ -62,13 +62,6 @@ def _postprocessing_inputs(): ] -def _reve_price_extractor(headers: dict) -> float | None: - credits_used = headers.get("x-reve-credits-used") - if credits_used is not None: - return float(credits_used) / 524.48 - return None - - def _reve_response_header_validator(headers: dict) -> None: error_code = headers.get("x-reve-error-code") if error_code: @@ -180,7 +173,6 @@ class ReveImageCreateNode(IO.ComfyNode): headers={"Accept": "image/webp"}, ), as_binary=True, - price_extractor=_reve_price_extractor, response_header_validator=_reve_response_header_validator, data=ReveImageCreateRequest( prompt=prompt, @@ -279,7 +271,6 @@ class ReveImageEditNode(IO.ComfyNode): headers={"Accept": "image/webp"}, ), as_binary=True, - price_extractor=_reve_price_extractor, response_header_validator=_reve_response_header_validator, data=ReveImageEditRequest( edit_instruction=edit_instruction, @@ -396,7 +387,6 @@ class ReveImageRemixNode(IO.ComfyNode): headers={"Accept": "image/webp"}, ), as_binary=True, - price_extractor=_reve_price_extractor, response_header_validator=_reve_response_header_validator, data=ReveImageRemixRequest( prompt=prompt, diff --git a/comfy_api_nodes/util/client.py b/comfy_api_nodes/util/client.py index 66aab17f8..039e97d58 100644 --- a/comfy_api_nodes/util/client.py +++ b/comfy_api_nodes/util/client.py @@ -2,8 +2,10 @@ import asyncio import contextlib import json import logging +import math import time import uuid +import weakref from collections.abc import Callable, Iterable from dataclasses import dataclass from enum import Enum @@ -84,11 +86,37 @@ class _PollUIState: _RETRY_STATUS = {408, 500, 502, 503, 504} # status 429 is handled separately _MAX_RETRY_AFTER_WAIT = 150.0 # Cap a server Retry-After at this many seconds so a large hint can't block execution + +PRICE_CREDITS_HEADER = "X-Comfy-Credits-Used" +"""Proxy response header with the actual cost in Comfy credits. When present on any successful proxied response, +it takes precedence over ``price_extractor``.""" + +_credits_used_by_execution: "weakref.WeakKeyDictionary[type, float]" = weakref.WeakKeyDictionary() +"""Last PRICE_CREDITS_HEADER value per node execution, keyed by the node's per-execution class clone.""" COMPLETED_STATUSES = ["succeeded", "succeed", "success", "completed", "finished", "done", "complete"] FAILED_STATUSES = ["cancelled", "canceled", "canceling", "fail", "failed", "error"] QUEUED_STATUSES = ["created", "queued", "queueing", "submitted", "initializing", "wait", "in_queue"] +def _maybe_remember_credits_used(node_cls: type[IO.ComfyNode], header_value: str | None) -> None: + """Remember a PRICE_CREDITS_HEADER value from a successful proxied response.""" + if not header_value: + return + try: + credits_used = float(header_value) + except (TypeError, ValueError): + logging.debug("Ignoring malformed %s header: %r", PRICE_CREDITS_HEADER, header_value) + return + if not math.isfinite(credits_used) or credits_used < 0: + logging.debug("Ignoring out-of-range %s header: %r", PRICE_CREDITS_HEADER, header_value) + return + _credits_used_by_execution[node_cls] = credits_used + 0.0 # normalize -0.0 + + +def _get_remembered_credits_used(node_cls: type[IO.ComfyNode]) -> float | None: + return _credits_used_by_execution.get(node_cls) + + async def sync_op( cls: type[IO.ComfyNode], endpoint: ApiEndpoint, @@ -450,10 +478,15 @@ def _display_text( display_lines: list[str] = [] if status: display_lines.append(f"Status: {status.capitalize() if isinstance(status, str) else status}") - if price is not None: + server_credits = _get_remembered_credits_used(node_cls) + if server_credits is not None: + p = f"{server_credits:,.2f}".rstrip("0").rstrip(".") + elif price is not None: p = f"{float(price) * 211:,.1f}".rstrip("0").rstrip(".") - if p != "0": - display_lines.append(f"Price: {p} credits") + else: + p = None + if p is not None and p != "0": + display_lines.append(f"Price: {p} credits") if text is not None: display_lines.append(text) if display_lines: @@ -606,7 +639,8 @@ async def _request_base(cfg: _RequestConfig, expect_binary: bool): """Core request with retries, per-second interruption monitoring, true cancellation, and friendly errors.""" url = cfg.endpoint.path parsed_url = urlparse(url) - if not parsed_url.scheme and not parsed_url.netloc: # is URL relative? + is_comfy_api_request = not parsed_url.scheme and not parsed_url.netloc # is URL relative? + if is_comfy_api_request: url = urljoin(default_base_url().rstrip("/") + "/", url.lstrip("/")) method = cfg.endpoint.method @@ -644,7 +678,7 @@ async def _request_base(cfg: _RequestConfig, expect_binary: bool): logging.debug("[DEBUG] HTTP %s %s (attempt %d)", method, url, attempt) payload_headers = {"Accept": "*/*"} if expect_binary else {"Accept": "application/json"} - if not parsed_url.scheme and not parsed_url.netloc: # is URL relative? + if is_comfy_api_request: payload_headers.update(get_comfy_api_headers(cfg.node_cls)) if cfg.endpoint.headers: payload_headers.update(cfg.endpoint.headers) @@ -804,6 +838,8 @@ async def _request_base(cfg: _RequestConfig, expect_binary: bool): ) bytes_payload = bytes(buff) resp_headers = {k.lower(): v for k, v in resp.headers.items()} + if is_comfy_api_request: + _maybe_remember_credits_used(cfg.node_cls, resp.headers.get(PRICE_CREDITS_HEADER)) if cfg.price_extractor: with contextlib.suppress(Exception): extracted_price = cfg.price_extractor(resp_headers) @@ -831,6 +867,8 @@ async def _request_base(cfg: _RequestConfig, expect_binary: bool): except json.JSONDecodeError: payload = {"_raw": text} response_content_to_log = payload if isinstance(payload, dict) else text + if is_comfy_api_request: + _maybe_remember_credits_used(cfg.node_cls, resp.headers.get(PRICE_CREDITS_HEADER)) with contextlib.suppress(Exception): extracted_price = cfg.price_extractor(payload) if cfg.price_extractor else None operation_succeeded = True