Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 20 additions & 8 deletions .github/workflows/block-cursor-coauthor.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# Reject PR commits that include Cursor as Co-authored-by.
# GitHub free repos cannot use ruleset commit_message_pattern; this check is the substitute.
# Use Windows PowerShell — Git Bash mangles ACTIONS_TEMP paths (C:\ → C:),
# and this runner may not have PowerShell 7 (pwsh) installed.
name: Block Cursor co-author

on:
Expand All @@ -16,7 +18,7 @@ jobs:
runs-on: [self-hosted, Windows, ci]
defaults:
run:
shell: bash
shell: powershell
steps:
- name: Checkout
uses: actions/checkout@v4
Expand All @@ -28,11 +30,21 @@ jobs:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
set -euo pipefail
bad="$(git log --format=%B "${BASE_SHA}..${HEAD_SHA}" | grep -F 'Co-authored-by: Cursor' || true)"
if [ -n "$bad" ]; then
echo "::error::Commits must not include 'Co-authored-by: Cursor'. Amend/rebase without that trailer, then push."
git log --format='== %h%n%B' "${BASE_SHA}..${HEAD_SHA}" | grep -n -F 'Co-authored-by: Cursor' -B5 || true
$base = $env:BASE_SHA
$head = $env:HEAD_SHA
if (-not $base -or -not $head) {
Write-Error "Missing BASE_SHA or HEAD_SHA"
exit 1
fi
echo "OK: no Cursor Co-authored-by trailer in PR commits."
}
$bodies = git log --format=%B "${base}..${head}" 2>&1
if ($LASTEXITCODE -ne 0) {
Write-Error "git log failed: $bodies"
exit 1
}
$hit = $bodies | Select-String -SimpleMatch 'Co-authored-by: Cursor'
if ($hit) {
Write-Host "::error::Commits must not include 'Co-authored-by: Cursor'. Amend/rebase without that trailer, then push."
git log --format='== %h%n%B' "${base}..${head}" | Select-String -SimpleMatch 'Co-authored-by: Cursor' -Context 5,0
exit 1
}
Write-Host 'OK: no Cursor Co-authored-by trailer in PR commits.'
2 changes: 2 additions & 0 deletions apps/api/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ S3_ENABLED=false
# VISION_EDIT_ELEMENTS_PROVIDER=seedream
# VISION_SEEDREAM_LAYER_MODEL=doubao-seedream-5-0-pro-260628
# VISION_SEEDREAM_TIMEOUT_SEC=180
# When local MinIO (localhost:9000), remote Seedream needs a public CDN base:
# VISION_PUBLIC_BASE_URL=https://files.recombyn.com/recombyn
# DASHSCOPE_API_KEY=
# BYTEPLUS_API_KEY=
# TOPAZ_API_KEY=
Expand Down
8 changes: 7 additions & 1 deletion apps/api/app/api/routes/image_process_jobs.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,12 @@

from app.api.deps import CurrentUser
from app.api.routes.chat_job_sse import streaming_media_job_events
from app.api.routes.image_tools import ImageProcessIn, _charge, credit_cost_for_kind
from app.api.routes.image_tools import (
ImageProcessIn,
_charge,
_require_free_vision_quota,
credit_cost_for_kind,
)
from app.services.i18n.errors import http_error
from app.services.i18n.locale import LocaleDep
from app.services.job_store import get_job, normalize_trace_id, save_job, update_job
Expand Down Expand Up @@ -167,6 +172,7 @@ async def create_image_process_job(
raise http_error(400, "image_required", locale)

cost = credit_cost_for_kind(tool_kind, body.model, user_id=current_user.id)
_require_free_vision_quota(current_user.id, cost=cost, locale=locale)
_charge(current_user.id, cost, f"AI image tool: {tool_kind}", locale=locale)

job_id = uuid.uuid4().hex
Expand Down
51 changes: 44 additions & 7 deletions apps/api/app/api/routes/image_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,22 @@
uses_llm_for_kind,
)
from app.services.wallet.billing import DEFAULT_IMAGE_CREDITS, image_model_credit_cost
from app.services.wallet.db import is_wallet_billing_enabled, spend_credits
from app.services.wallet.db import (
consume_free_vision_quota,
free_vision_remaining,
get_user_plan,
is_wallet_billing_enabled,
spend_credits,
)

router = APIRouter(prefix="/image", tags=["image-tools"])

# Wallet 积分 for LLM image tools only (Seedream i2i). MediaKit / WaveSpeed / layered → 0.
# Wallet 积分 for paid vision / LLM image paths.
# MediaKit tools (removeBg / expand / …) stay 0 — free users use freeVision quota.
_KIND_CREDIT_COST: dict[str, int] = {
"replaceText": 30,
# Seedream layer_decomposition / WaveSpeed qwen-image/layered
"editElements": 30,
}


Expand All @@ -49,28 +58,46 @@ def _charge(user_id: str, amount: int, detail: str, *, locale: str | None = None
raise http_error(400, "request_failed", locale) from err


def _require_free_vision_quota(
user_id: str,
*,
cost: int,
locale: str | None = None,
) -> None:
"""Free plan: lifetime cap on zero-credit vision toolbar tools."""
if cost > 0 or not is_wallet_billing_enabled():
return
if str(get_user_plan(user_id) or "free").strip().lower() != "free":
return
if consume_free_vision_quota(user_id):
return
raise http_error(402, "free_vision_exhausted", locale)


def credit_cost_for_kind(
kind: str,
model: str | None = None,
*,
user_id: str | None = None,
) -> int:
"""Platform credits only when an LLM image path runs on the platform key."""
"""Platform credits for paid image tools (LLM i2i + Seedream/WaveSpeed layered)."""
k = (kind or "").strip()
if not uses_llm_for_kind(k):
return 0
if not is_wallet_billing_enabled():
return 0
if is_byok_model_ref(model) or (user_id and uses_user_platform_byok(user_id, model)):
return 0
if k in _KIND_CREDIT_COST:
return int(_KIND_CREDIT_COST[k])
if not uses_llm_for_kind(k):
return 0
mid = (model or "").strip()
if mid:
return image_model_credit_cost(mid)
return int(_KIND_CREDIT_COST.get(k, DEFAULT_IMAGE_CREDITS))
return int(DEFAULT_IMAGE_CREDITS)


@router.get("/tools")
def list_image_tools() -> dict[str, Any]:
def list_image_tools(current_user: CurrentUser) -> dict[str, Any]:
from app.core.config import settings
from app.services.llm.image_tools import (
mediakit_supports,
Expand All @@ -82,6 +109,7 @@ def list_image_tools() -> dict[str, Any]:
seedream_enabled,
wavespeed_enabled,
)
from app.services.wallet.db import FREE_VISION_LIMIT

kinds = sorted(IMAGE_PROCESS_KINDS)
costs = {k: credit_cost_for_kind(k) for k in kinds}
Expand All @@ -91,6 +119,10 @@ def list_image_tools() -> dict[str, Any]:
dash_on = bool(str(settings.dashscope_api_key or "").strip())
byte_on = bool(str(settings.byteplus_api_key or "").strip())
topaz_on = bool(str(settings.topaz_api_key or "").strip())
plan = str(get_user_plan(current_user.id) or "free").strip().lower()
vision_remaining = None
if is_wallet_billing_enabled() and plan == "free":
vision_remaining = free_vision_remaining(current_user.id)
return {
"kinds": kinds,
"credits": costs,
Expand Down Expand Up @@ -119,6 +151,10 @@ def list_image_tools() -> dict[str, Any]:
},
# FE mockup is client-side only (no server bake API).
"mockup": {"enabled": True, "templates": []},
"freeVision": {
"limit": FREE_VISION_LIMIT,
"remaining": vision_remaining,
},
}


Expand All @@ -130,6 +166,7 @@ async def post_image_process(
) -> dict[str, Any]:
kind = body.kind.strip()
cost = credit_cost_for_kind(kind, body.model, user_id=current_user.id)
_require_free_vision_quota(current_user.id, cost=cost, locale=locale)
_charge(current_user.id, cost, f"AI image tool: {kind}", locale=locale)

try:
Expand Down
28 changes: 6 additions & 22 deletions apps/api/app/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -156,28 +156,6 @@ class Settings(BaseSettings):

agent_profile_id: str = "design.canvas"

# Design agent floors (ADR 0017). Always BasicLocal in-process — no remote Intelligence service.
intelligence_provider: str = Field(
default="local",
validation_alias="RECOMBYN_INTELLIGENCE_MODE",
)
intelligence_remote_url: str = Field(
default="",
validation_alias="RECOMBYN_INTELLIGENCE_URL",
)
intelligence_remote_api_key: str = Field(
default="",
validation_alias="RECOMBYN_INTELLIGENCE_API_KEY",
)
intelligence_remote_timeout_sec: float = Field(
default=30.0,
validation_alias="RECOMBYN_INTELLIGENCE_TIMEOUT_SEC",
)
intelligence_circuit_sec: float = Field(
default=30.0,
validation_alias="RECOMBYN_INTELLIGENCE_CIRCUIT_SEC",
)

# Volcengine AI MediaKit (OSS: removeBg, expand, editText, eraser, upscale, translateImage, productScene).
mediakit_api_key: str = Field(
default="",
Expand Down Expand Up @@ -221,6 +199,12 @@ class Settings(BaseSettings):
default=180.0,
validation_alias="VISION_SEEDREAM_TIMEOUT_SEC",
)
# When S3_PUBLIC_BASE_URL is localhost/MinIO, remote APIs (Seedream/WaveSpeed)
# need a reachable CDN base (prod: https://files.recombyn.com/recombyn).
vision_public_base_url: str = Field(
default="",
validation_alias="VISION_PUBLIC_BASE_URL",
)

# Reserved for later vision vendors (keys only — no layered clients yet).
dashscope_api_key: str = Field(default="", validation_alias="DASHSCOPE_API_KEY")
Expand Down
49 changes: 7 additions & 42 deletions apps/api/app/services/design/intelligence_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -444,57 +444,22 @@ def reset_design_intelligence_client() -> None:
_client = None

def remote_billing_base_url() -> str:
"""No remote Intelligence host — billing quotes stay local."""
"""No remote Intelligence billing host in the open tree."""
return ""


def _remote_billing_headers() -> dict[str, str]:
key = str(getattr(settings, "intelligence_remote_api_key", "") or "").strip()
headers = {"Content-Type": "application/json"}
if key:
headers["Authorization"] = f"Bearer {key}"
return headers


def call_remote_billing(
method: str,
path: str,
*,
json_body: dict[str, Any] | None = None,
) -> dict[str, Any] | None:
"""HTTP call to optional host ``/billing/quote``. Returns None if unavailable."""
import urllib.error
import urllib.request

base = remote_billing_base_url()
if not base:
return None
url = f"{base}{path}"
timeout = float(getattr(settings, "intelligence_remote_timeout_sec", 30.0) or 30.0)
data = None
headers = _remote_billing_headers()
if json_body is not None or method.upper() in ("POST", "PUT"):
import json as _json

raw = _json.dumps(json_body or {}).encode("utf-8")
data = raw
req = urllib.request.Request(
url,
data=data,
headers=headers,
method=method.upper(),
)
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
import json as _json

body = _json.loads(resp.read().decode("utf-8"))
return body if isinstance(body, dict) else None
except (urllib.error.URLError, urllib.error.HTTPError, TimeoutError, ValueError) as e:
_log.warning("remote billing %s %s failed: %s", method, path, e)
return None
"""Optional remote billing — always unavailable (BasicLocal only)."""
_ = method, path, json_body
return None


def quote_remote_task_credits(body: dict[str, Any]) -> dict[str, Any] | None:
"""Optional host credit quote — wire returns credits only."""
return call_remote_billing("POST", "/billing/quote", json_body=body)
"""Optional host credit quote — not wired in the open tree."""
_ = body
return None
Loading
Loading