diff --git a/VERSION b/VERSION index 2391fa01..edf342dc 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -4.9.5 +4.9.6 diff --git a/gas/__init__.py b/gas/__init__.py index 5ad2022b..6de8f76f 100644 --- a/gas/__init__.py +++ b/gas/__init__.py @@ -1,4 +1,4 @@ -__version__ = "4.9.5" +__version__ = "4.9.6" version_split = __version__.split(".") __spec_version__ = ( diff --git a/gas/cache/content_manager.py b/gas/cache/content_manager.py index 813ed24c..d45a9628 100644 --- a/gas/cache/content_manager.py +++ b/gas/cache/content_manager.py @@ -217,15 +217,19 @@ def write_miner_media( resolution, file_size = extract_media_info(save_path, modality) - # Audio presence feeds resolution/audio-tiered reward pricing. + # Audio presence and duration feed the tiered reward pricing. has_audio = None + duration_seconds = None if modality == Modality.VIDEO: try: from gas.cache.util.video import get_video_metadata - has_audio = bool(get_video_metadata(save_path).get("has_audio")) + video_meta = get_video_metadata(save_path) + has_audio = bool(video_meta.get("has_audio")) + raw_duration = video_meta.get("duration") + duration_seconds = float(raw_duration) if raw_duration else None except Exception as e: - bt.logging.warning(f"Could not detect audio track in {save_path}: {e}") + bt.logging.warning(f"Could not probe video metadata in {save_path}: {e}") media_id = self.media.add_media_entry( prompt_id=prompt_id, @@ -246,6 +250,7 @@ def write_miner_media( c2pa_issuer=c2pa_issuer, task_id=task_id, has_audio=has_audio, + duration_seconds=duration_seconds, ) self.challenges.update_outcome( task_id=task_id, @@ -624,6 +629,7 @@ def record_challenge_outcome( media_id: Optional[str] = None, created_at: Optional[float] = None, requested_resolution: Optional[str] = None, + requested_duration: Optional[float] = None, ) -> bool: return self.challenges.record_outcome( task_id=task_id, @@ -636,6 +642,7 @@ def record_challenge_outcome( media_id=media_id, created_at=created_at, requested_resolution=requested_resolution, + requested_duration=requested_duration, ) def update_challenge_outcome( diff --git a/gas/cache/db/challenge_store.py b/gas/cache/db/challenge_store.py index b18ac2a0..7b22e0a6 100644 --- a/gas/cache/db/challenge_store.py +++ b/gas/cache/db/challenge_store.py @@ -44,6 +44,7 @@ def record_outcome( media_id: Optional[str] = None, created_at: Optional[float] = None, requested_resolution: Optional[str] = None, + requested_duration: Optional[float] = None, ) -> bool: """Insert or update a generation challenge outcome.""" try: @@ -54,9 +55,10 @@ def record_outcome( """ INSERT INTO generator_challenge_outcomes ( task_id, uid, hotkey, prompt_id, modality, status, - failure_reason, media_id, requested_resolution, created_at, updated_at + failure_reason, media_id, requested_resolution, + requested_duration, created_at, updated_at ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(task_id) DO UPDATE SET uid = excluded.uid, hotkey = excluded.hotkey, @@ -66,9 +68,10 @@ def record_outcome( failure_reason = excluded.failure_reason, media_id = COALESCE(excluded.media_id, generator_challenge_outcomes.media_id), requested_resolution = COALESCE(excluded.requested_resolution, generator_challenge_outcomes.requested_resolution), + requested_duration = COALESCE(excluded.requested_duration, generator_challenge_outcomes.requested_duration), updated_at = excluded.updated_at """, - (task_id, uid, hotkey, prompt_id, modality, status, failure_reason, media_id, requested_resolution, created_at, now), + (task_id, uid, hotkey, prompt_id, modality, status, failure_reason, media_id, requested_resolution, requested_duration, created_at, now), ) conn.commit() return True @@ -158,7 +161,8 @@ def get_outcomes_last_n_hours( conn.row_factory = sqlite3.Row cursor = conn.execute( """ - SELECT o.*, m.resolution AS media_resolution, m.has_audio AS media_has_audio + SELECT o.*, m.resolution AS media_resolution, m.has_audio AS media_has_audio, + m.duration_seconds AS media_duration FROM generator_challenge_outcomes o LEFT JOIN media m ON o.media_id = m.id WHERE o.status IN ('verified', 'failed') AND o.updated_at >= ? @@ -184,6 +188,10 @@ def get_outcomes_last_n_hours( has_audio=( bool(row["media_has_audio"]) if row["media_has_audio"] is not None else None ), + requested_duration=( + row["requested_duration"] if "requested_duration" in row.keys() else None + ), + observed_duration=row["media_duration"], created_at=row["created_at"], updated_at=row["updated_at"], ) @@ -239,6 +247,8 @@ def get_outcome_stats_last_n_hours( if outcome.observed_resolution else None ), "has_audio": outcome.has_audio, + "requested_duration": outcome.requested_duration, + "observed_duration": outcome.observed_duration, }) if outcome.media_id: miner_stats[hotkey]["verified_media_ids"].append(outcome.media_id) diff --git a/gas/cache/db/media_store.py b/gas/cache/db/media_store.py index dfce7843..09144df5 100644 --- a/gas/cache/db/media_store.py +++ b/gas/cache/db/media_store.py @@ -68,6 +68,7 @@ def _row_to_media_entry(row) -> MediaEntry: c2pa_verified=bool(row["c2pa_verified"]) if "c2pa_verified" in row.keys() and row["c2pa_verified"] is not None else False, c2pa_issuer=row["c2pa_issuer"] if "c2pa_issuer" in row.keys() else None, has_audio=bool(row["has_audio"]) if "has_audio" in row.keys() and row["has_audio"] is not None else None, + duration_seconds=row["duration_seconds"] if "duration_seconds" in row.keys() else None, ) # ------------------------------------------------------------------ @@ -127,6 +128,7 @@ def add_media_entry( c2pa_issuer: Optional[str] = None, task_id: Optional[str] = None, has_audio: Optional[bool] = None, + duration_seconds: Optional[float] = None, ) -> str: """Add a media entry to the database. Returns the media id.""" media_id = str(uuid.uuid4()) @@ -141,9 +143,10 @@ def add_media_entry( model_name, generation_args, uid, hotkey, verified, failed_verification, rewarded, created_at, mask_path, timestamp, resolution, file_size, format, - perceptual_hash, c2pa_verified, c2pa_issuer, task_id, has_audio + perceptual_hash, c2pa_verified, c2pa_issuer, task_id, has_audio, + duration_seconds ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( media_id, prompt_id, file_path, modality.value, media_type.value, source_type.value, @@ -158,6 +161,7 @@ def add_media_entry( c2pa_issuer, task_id, 1 if has_audio else 0 if has_audio is not None else None, + duration_seconds, ), ) conn.commit() diff --git a/gas/cache/db/migrations.py b/gas/cache/db/migrations.py index 97bc71ec..c97c8bf3 100644 --- a/gas/cache/db/migrations.py +++ b/gas/cache/db/migrations.py @@ -78,6 +78,13 @@ "CREATE INDEX IF NOT EXISTS idx_prompts_register ON prompts (register)", ], ), + ( + "add_duration_columns", + [ + "ALTER TABLE generator_challenge_outcomes ADD COLUMN requested_duration REAL", + "ALTER TABLE media ADD COLUMN duration_seconds REAL", + ], + ), ] diff --git a/gas/cache/types.py b/gas/cache/types.py index cf44317e..bef665bc 100644 --- a/gas/cache/types.py +++ b/gas/cache/types.py @@ -89,6 +89,9 @@ class MediaEntry: # Video audio track presence (None = unknown / not applicable) has_audio: Optional[bool] = None + # Video duration in seconds (None = unknown / not applicable) + duration_seconds: Optional[float] = None + # Miner challenge tracking task_id: Optional[str] = None @@ -147,6 +150,8 @@ class ChallengeOutcome: requested_resolution: Optional[str] = None # tier requested in the challenge (e.g. "1080p") observed_resolution: Optional[tuple] = None # (width, height) of the stored media has_audio: Optional[bool] = None # audio track present in the stored media + requested_duration: Optional[float] = None # video seconds requested in the challenge + observed_duration: Optional[float] = None # seconds of the stored media (ffprobe) created_at: float = None updated_at: float = None diff --git a/gas/evaluation/generative_challenge_manager.py b/gas/evaluation/generative_challenge_manager.py index 94097bec..3af20cb9 100644 --- a/gas/evaluation/generative_challenge_manager.py +++ b/gas/evaluation/generative_challenge_manager.py @@ -21,7 +21,7 @@ from typing import Dict, Optional from gas.cache.content_manager import ContentManager -from gas.evaluation.resolution_tiers import sample_challenge_tier +from gas.evaluation.resolution_tiers import sample_challenge_duration, sample_challenge_tier from gas.protocol.epistula import get_verifier from gas.protocol.validator_requests import query_generative_miner from gas.types import MediaType, MinerType, Modality @@ -157,6 +157,14 @@ async def send_generative_request(self, uid: int, prompt_entry, modality: Modali requested_resolution = sample_challenge_tier(modality.value) parameters = {"resolution": requested_resolution} + # Video challenges also request a duration; reward pricing scales with + # min(delivered, requested) seconds so shorter deliveries earn less + # rather than failing (mirrors the resolution tier mechanism). + requested_duration = None + if modality == Modality.VIDEO: + requested_duration = sample_challenge_duration() + parameters["duration"] = requested_duration + async with aiohttp.ClientSession() as session: response_data = await query_generative_miner( uid=uid, @@ -184,6 +192,7 @@ async def send_generative_request(self, uid: int, prompt_entry, modality: Modali "status": "pending", "sent_at": time.time(), "requested_resolution": requested_resolution, + "requested_duration": requested_duration, } self.content_manager.record_challenge_outcome( task_id=miner_task_id, @@ -193,6 +202,7 @@ async def send_generative_request(self, uid: int, prompt_entry, modality: Modali modality=modality.value, status="pending", requested_resolution=requested_resolution, + requested_duration=requested_duration, ) bt.logging.info( f"Stored challenge task {miner_task_id} for UID {uid}. Total active tasks: {len(self.challenge_tasks)}" diff --git a/gas/evaluation/resolution_tiers.py b/gas/evaluation/resolution_tiers.py index 8f806fa3..d1be87a8 100644 --- a/gas/evaluation/resolution_tiers.py +++ b/gas/evaluation/resolution_tiers.py @@ -50,6 +50,27 @@ }, } +# Weighted distribution for sampling the requested video duration (seconds). +# All trusted video models accept 4/6/8 (Veo exactly these; Seedance 4-15). +# Miners previously converged on the 4s provider minimum because duration was +# never requested and rewards were duration-invariant; reward pricing now +# scales with min(delivered, requested) seconds (see rewards), so requesting +# longer durations probes real capability and diversifies the dataset. +# Weighted toward 4s to bound miner cost (expected duration ~5.2s). +CHALLENGE_DURATION_WEIGHTS: Dict[int, float] = { + 4: 0.50, + 6: 0.30, + 8: 0.20, +} + + +def sample_challenge_duration(rng: random.Random = random) -> int: + """Sample a requested video duration (seconds) from the weight distribution.""" + durations = list(CHALLENGE_DURATION_WEIGHTS.keys()) + weights = list(CHALLENGE_DURATION_WEIGHTS.values()) + return rng.choices(durations, weights=weights, k=1)[0] + + # Video classification thresholds on *total pixel count*, which is both # orientation-invariant and aspect-ratio-invariant. Min-dimension thresholds # were exploitable via square output: a 640x640 video has the pixel count diff --git a/gas/evaluation/rewards.py b/gas/evaluation/rewards.py index 24a59a8f..427ce31a 100644 --- a/gas/evaluation/rewards.py +++ b/gas/evaluation/rewards.py @@ -29,8 +29,8 @@ # both route through ByteDance's own C2PA signing (sig issuer: Byteplus Pte. Ltd.). # Confirmed via live test: seedance-1-5-pro has NO C2PA on OpenRouter and is # not offered on Runway — only the 2.0 variants are validator-eligible. - "dreamina-seedance-2-0-fast": 0.152, # $0.76 per 5s at 720p (provider quote, 2026-07) - "dreamina-seedance-2-0": 0.40, # $2.00 per 5s at 720p (provider quote, 2026-07) + "dreamina-seedance-2-0-fast": 0.121, # OpenRouter token rate at 720p (see NAMED_MODEL_TIER_PRICES) + "dreamina-seedance-2-0": 0.151, # OpenRouter token rate at 720p (see NAMED_MODEL_TIER_PRICES) # Runway gen4.5: C2PA manifest present but claimSignature.mismatch — validators # reject all gen4.5 content until Runway fixes their signing infra. # "RunwayML": 0.05, # re-enable when gen4.5 signature is fixed @@ -63,10 +63,11 @@ # Per-tier USD/s prices for C2PA-blind Veo generations (model_name=None). # Google's manifests never expose the variant, so pricing is by delivered -# tier only (audio-independent): 480p/720p match Seedance fast's rates -# (same resolution, same multiplier), and 1080p reflects that Veo's real -# price rises with resolution — $0.40/s is the veo-3.1 standard 1080p rate -# (also Seedance full's 720p rate), i.e. a 3.65x multiplier. +# tier only (audio-independent), and 1080p reflects that Veo's real price +# rises with resolution — $0.40/s is the veo-3.1 standard 1080p rate, +# i.e. a 3.65x multiplier. (Deliberately NOT floored to the cheapest +# Seedance route: Veo has no token-priced route, and the team chose to pay +# Veo miners at Veo-family rates even without a variant name.) _VEO_TIER_PRICES: Dict[str, float] = { "480p": 0.07, "720p": 0.152, @@ -74,25 +75,42 @@ } # Per-tier USD/s prices for named models (C2PA exposes model_name). -# OpenRouter publishes no Seedance pricing SKUs, so these come from provider -# quotes (2026-07, per 5s video): seedance-2-0 $0.50 / $2.00 / $2.40 at -# 480p / 720p / 1080p; seedance-2-0-fast $0.35 / $0.76 at 480p / 720p. -# Note the spread is NOT pixel-proportional (full jumps ~4x from 480p to -# 720p but only ~1.2x from 720p to 1080p), which is why these are explicit -# tables rather than a shared scale. Keys are matched against the C2PA -# model_name the same way as GENERATOR_MODEL_PRICES (exact, then substring). +# +# Priced at the CHEAPEST acquisition route consistent with the C2PA evidence +# (price-floor principle): OpenRouter sells Seedance 2.0 by video token with +# the identical ByteDance C2PA signature as the direct provider route, so the +# provider's quoted prices (up to ~2.7x higher at 720p) would overpay. Token +# math: tokens/s = width*height*24fps/1024, so USD/s = token_rate * tokens/s. +# Defaults below use the 2026-07 token rates (seedance-2-0 $7e-6/token, +# fast $5.6e-6) at each tier's output shape (480p 864x496, 720p 1280x720, +# 1080p 1920x1080); live token rates are merged on top at module load. +# Keys are matched against the C2PA model_name the same way as +# GENERATOR_MODEL_PRICES (exact, then substring). NAMED_MODEL_TIER_PRICES: Dict[str, Dict[str, float]] = { "dreamina-seedance-2-0-fast": { - "480p": 0.07, # $0.35 / 5s - "720p": 0.152, # $0.76 / 5s + "480p": 0.056, + "720p": 0.121, }, "dreamina-seedance-2-0": { - "480p": 0.10, # $0.50 / 5s - "720p": 0.40, # $2.00 / 5s - "1080p": 0.48, # $2.40 / 5s + "480p": 0.070, + "720p": 0.151, + "1080p": 0.340, }, } +# OpenRouter model id -> C2PA model_name, output pixels per tier, and fps — +# used to convert live video_tokens SKU rates into NAMED_MODEL_TIER_PRICES. +_OPENROUTER_NAMED_VIDEO_MODELS: Dict[str, str] = { + "bytedance/seedance-2.0": "dreamina-seedance-2-0", + "bytedance/seedance-2.0-fast": "dreamina-seedance-2-0-fast", +} +_SEEDANCE_TIER_PIXELS: Dict[str, int] = { + "480p": 864 * 496, + "720p": 1280 * 720, + "1080p": 1920 * 1080, +} +_SEEDANCE_FPS = 24 + # Fallback resolution scaling for named models with no NAMED_MODEL_TIER_PRICES # table: their flat 720p reference is scaled by the delivered tier's pixel # ratio (480p is ~0.44x the 720p pixel count, 1080p ~2.25x). @@ -155,14 +173,18 @@ def _parse_duration_sku_key(key: str) -> Optional[Tuple[str, bool]]: return tier, has_audio -def _fetch_openrouter_prices() -> Tuple[Dict[str, float], Dict[str, Dict[Tuple[str, bool], float]]]: +def _fetch_openrouter_prices() -> Tuple[ + Dict[str, float], + Dict[str, Dict[Tuple[str, bool], float]], + Dict[str, Dict[str, float]], +]: """Pull live video model prices from OpenRouter /videos/models (free, no auth). - Returns (flat_prices, tier_prices). Only runs once per process — result is - cached at module level.""" + Returns (flat_prices, tier_prices, named_tier_prices). Only runs once per + process — result is cached at module level.""" global _LIVE_PRICES_FETCHED if _LIVE_PRICES_FETCHED: - return {}, {} + return {}, {}, {} _LIVE_PRICES_FETCHED = True try: @@ -172,10 +194,11 @@ def _fetch_openrouter_prices() -> Tuple[Dict[str, float], Dict[str, Dict[Tuple[s timeout=10, ) if resp.status_code != 200: - return {}, {} + return {}, {}, {} models = resp.json().get("data", []) prices: Dict[str, float] = {} tier_prices: Dict[str, Dict[Tuple[str, bool], float]] = {} + named_tier_prices: Dict[str, Dict[str, float]] = {} for m in models: model_id = m.get("id", "") skus = m.get("pricing_skus", {}) or {} @@ -197,20 +220,41 @@ def _fetch_openrouter_prices() -> Tuple[Dict[str, float], Dict[str, Dict[Tuple[s prices[model_id] = min(candidates) if tier_table: tier_prices[model_id] = tier_table + + # Token-priced named models (Seedance): convert the cheapest + # video_tokens rate to USD/s per tier via pixels * fps / 1024. + c2pa_name = _OPENROUTER_NAMED_VIDEO_MODELS.get(model_id) + if c2pa_name: + token_rates = [] + for key in ("video_tokens", "video_tokens_without_audio"): + try: + token_rates.append(float(skus[key])) + except (KeyError, ValueError, TypeError): + continue + if token_rates: + rate = min(token_rates) + named_tier_prices[c2pa_name] = { + tier: rate * pixels * _SEEDANCE_FPS / 1024 + for tier, pixels in _SEEDANCE_TIER_PIXELS.items() + # only tiers the model actually offers (fast has no 1080p) + if tier in NAMED_MODEL_TIER_PRICES.get(c2pa_name, {}) + } bt.logging.info(f"Fetched {len(prices)} live OpenRouter video model prices") - return prices, tier_prices + return prices, tier_prices, named_tier_prices except Exception as e: bt.logging.debug(f"Could not fetch OpenRouter prices: {e}") - return {}, {} + return {}, {}, {} # Merge live prices on top of defaults (live wins over hardcoded for same key). -_live, _live_tiers = _fetch_openrouter_prices() +_live, _live_tiers, _live_named_tiers = _fetch_openrouter_prices() if _live: GENERATOR_MODEL_PRICES = {**GENERATOR_MODEL_PRICES, **_live} _GENERATOR_BASELINE_PRICE = min(GENERATOR_MODEL_PRICES.values()) if _live_tiers: MODEL_TIER_PRICES = {**MODEL_TIER_PRICES, **_live_tiers} +for _name, _table in _live_named_tiers.items(): + NAMED_MODEL_TIER_PRICES[_name] = {**NAMED_MODEL_TIER_PRICES.get(_name, {}), **_table} def _get_model_price(model_name: str) -> float: @@ -298,25 +342,80 @@ def _get_video_generation_price(generation: Dict[str, Any]) -> float: # costs a volume slot, so the discount is not dodgeable by refusal. VIDEO_UNDERSHOOT_TIER_DISCOUNT = 0.6 +# Reference video duration (seconds). Providers' minimum — and what every +# miner delivered while duration was unrequested — so pricing effective +# seconds against this baseline leaves historical multipliers unchanged. +_BASELINE_VIDEO_SECONDS = 4.0 + +# Delivered durations within this many seconds of the request count as +# compliant (container/encoder duration jitter, e.g. 5.97s for a 6s gen). +_DURATION_TOLERANCE_SECONDS = 0.25 + + +def _effective_video_seconds(generation: Dict[str, Any]) -> float: + """Seconds a video generation is priced at: min(delivered, requested). + + Duration is part of real generation cost (providers bill per second), so + the multiplier scales with delivered seconds capped at the request — + overshooting never pays, and undershooting degrades smoothly under the + sqrt taper (deliver 4s on an 8s request: sqrt(4/8) = 0.71x that request's + full multiplier) rather than failing. Since cost is linear in seconds + and reward is sqrt, undershooting duration is per-dollar neutral and + per-slot strictly worse — no separate discount constant needed. + + Unknowns are conservative: no requested duration (pre-feature outcomes) + prices at the 4s baseline; a requested but unverifiable delivered + duration also earns only the baseline floor, capped at the request. + """ + requested = generation.get("requested_duration") + try: + requested = float(requested) if requested else _BASELINE_VIDEO_SECONDS + except (TypeError, ValueError): + requested = _BASELINE_VIDEO_SECONDS + observed = generation.get("observed_duration") + try: + observed = float(observed) if observed else None + except (TypeError, ValueError): + observed = None + if observed is None: + return min(_BASELINE_VIDEO_SECONDS, requested) + if observed >= requested - _DURATION_TOLERANCE_SECONDS: + return requested + return max(observed, 0.0) + +# Same mechanism for images. Without it, image undershoot carries no penalty +# beyond earning the lower tier's price — and providers with cheap low-tier +# output (e.g. ~$0.04 1K vs ~$0.14 2K Google image pricing) make ignoring the +# requested tier the better reward-per-dollar choice, the exact gradient the +# video discount exists to flip: e.g. on a 2K request, 2K earns sqrt(2)=1.41x +# while 1K earns 1.0*0.6=0.6x. +IMAGE_UNDERSHOOT_TIER_DISCOUNT = 0.6 + def _compute_video_generation_multiplier(generations: List[Dict[str, Any]]) -> float: """Average price multiplier across a miner's verified video generations. Same sqrt taper as _compute_average_model_multiplier, but priced per - generation from (model_name, resolution tier, audio) instead of model - name alone. Deliveries below the requested tier are discounted per tier - of shortfall (see VIDEO_UNDERSHOOT_TIER_DISCOUNT). + generation from (model_name, resolution tier, audio) and total effective + seconds instead of model name alone: the sqrt argument is the ratio of + the generation's total cost (USD/s price x min(delivered, requested) + seconds) to the baseline cost (baseline price x 4s), so a compliant 8s + delivery earns sqrt(2)x the multiplier of the same model at 4s. + Deliveries below the requested resolution tier are additionally + discounted per tier of shortfall (see VIDEO_UNDERSHOOT_TIER_DISCOUNT). """ if not generations: return 1.0 + baseline_cost = _GENERATOR_BASELINE_PRICE * _BASELINE_VIDEO_SECONDS total = 0.0 for generation in generations: price = _get_video_generation_price(generation) + seconds = _effective_video_seconds(generation) shortfall = tier_shortfall( generation.get("observed_resolution"), generation.get("requested_resolution"), ) - total += math.sqrt(price / _GENERATOR_BASELINE_PRICE) * ( + total += math.sqrt((price * seconds) / baseline_cost) * ( VIDEO_UNDERSHOOT_TIER_DISCOUNT ** shortfall ) return total / len(generations) @@ -342,13 +441,22 @@ def _compute_image_generation_multiplier(generations: List[Dict[str, Any]]) -> f """Average resolution-tier multiplier across a miner's verified images. Same sqrt taper as video: a tier 4x the baseline price earns 2x, not 4x. + Deliveries below the requested tier are discounted per tier of shortfall + (see IMAGE_UNDERSHOOT_TIER_DISCOUNT). """ if not generations: return 1.0 total = 0.0 for generation in generations: price = _get_image_generation_price(generation) - total += math.sqrt(price / _IMAGE_BASELINE_PRICE) + shortfall = tier_shortfall( + generation.get("observed_resolution"), + generation.get("requested_resolution"), + modality="image", + ) + total += math.sqrt(price / _IMAGE_BASELINE_PRICE) * ( + IMAGE_UNDERSHOOT_TIER_DISCOUNT ** shortfall + ) return total / len(generations) diff --git a/neurons/generator/services/openrouter_service.py b/neurons/generator/services/openrouter_service.py index dfdb434f..5a5c3faf 100644 --- a/neurons/generator/services/openrouter_service.py +++ b/neurons/generator/services/openrouter_service.py @@ -177,9 +177,13 @@ async def process(self, task: GenerationTask) -> Optional[Dict[str, Any]]: raise ValueError(f"OpenRouter async process() only supports images, got {modality}") model = parameters.get('model', self.default_model) - bt.logging.info(f"Generating image with OpenRouter model: {model}") - - api_result = await self._generate_image(prompt, model) + resolution = parameters.get('resolution') + bt.logging.info( + f"Generating image with OpenRouter model: {model}" + + (f" at {resolution}" if resolution else "") + ) + + api_result = await self._generate_image(prompt, model, resolution) if api_result is None: bt.logging.error("OpenRouter API returned None") @@ -236,19 +240,33 @@ def process_with_checkpoint( # ────────────────── Image Generation ────────────────── - async def _generate_image(self, prompt: str, model: str) -> Optional[Dict[str, Any]]: + async def _generate_image( + self, prompt: str, model: str, resolution: Optional[str] = None + ) -> Optional[Dict[str, Any]]: """ Generate an image using OpenRouter API. - - Based on the nano_banana.py implementation but adapted for async operation. - + + When the challenge requests a resolution tier ("1K"/"2K"/"4K"), the + dedicated /api/v1/images endpoint is used — it accepts a `resolution` + field directly, which the chat completions path has no equivalent for. + Falls back to chat completions if the images endpoint fails. + Args: prompt: The text prompt for image generation model: The model to use for generation - + resolution: Requested tier ("1K"/"2K"/"4K"), or None for default + Returns: Dict with generation results or None on failure """ + if resolution: + try: + return self._generate_image_at_resolution(prompt, model, resolution) + except Exception as e: + bt.logging.warning( + f"OpenRouter images endpoint failed at {resolution} " + f"({e}); falling back to chat completions (default size)" + ) headers = { "Authorization": f"Bearer {self.api_key}", "Content-Type": "application/json", @@ -377,7 +395,68 @@ async def _generate_image(self, prompt: str, model: str) -> Optional[Dict[str, A except Exception as e: bt.logging.error(f"OpenRouter image processing failed: {e}") raise - + + def _generate_image_at_resolution( + self, prompt: str, model: str, resolution: str + ) -> Dict[str, Any]: + """Generate via the dedicated /api/v1/images endpoint at a resolution tier. + + The endpoint accepts `resolution` values "1K"/"2K"/"4K" — the same + tier names validators send in challenge parameters — and returns + base64 image bytes in data[0].b64_json with original C2PA intact. + """ + headers = { + "Authorization": f"Bearer {self.api_key}", + "Content-Type": "application/json", + } + api_url = "https://openrouter.ai/api/v1/images" + payload = { + "model": model, + "prompt": prompt, + "resolution": resolution, + } + + start_time = time.time() + response = requests.post(api_url, headers=headers, json=payload, timeout=self.timeout) + if response.status_code != 200: + raise RuntimeError( + f"OpenRouter images API returned {response.status_code}: {response.text[:500]}" + ) + + result = response.json() + data = result.get("data") or [] + if not data or not data[0].get("b64_json"): + raise ValueError("OpenRouter images API response missing data[0].b64_json") + + image_binary = base64.b64decode(data[0]["b64_json"]) + media_type = data[0].get("media_type", "image/png") + image_format = media_type.split("/")[-1].upper().replace("JPG", "JPEG") + + pil_image = Image.open(io.BytesIO(image_binary)) + gen_time = time.time() - start_time + bt.logging.info( + f"OpenRouter images endpoint: {len(image_binary)} bytes, " + f"{pil_image.width}x{pil_image.height} (requested {resolution})" + ) + + return { + "image": pil_image, + "raw_binary": image_binary, # Preserve original bytes with C2PA + "format": image_format, + "modality": "image", + "media_type": "synthetic", + "prompt": prompt, + "model_name": model, + "time": time.time(), + "gen_duration": gen_time, + "gen_args": { + "provider": "openrouter", + "model": model, + "resolution": resolution, + "api_url": api_url, + }, + } + # ────────────────── Video Generation ────────────────── def _generate_video(