vcursor-cli 1.0.13__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- vcursor/__init__.py +25 -0
- vcursor/cli.py +322 -0
- vcursor/client.py +372 -0
- vcursor/types.py +222 -0
- vcursor_cli-1.0.13.dist-info/METADATA +139 -0
- vcursor_cli-1.0.13.dist-info/RECORD +9 -0
- vcursor_cli-1.0.13.dist-info/WHEEL +5 -0
- vcursor_cli-1.0.13.dist-info/entry_points.txt +2 -0
- vcursor_cli-1.0.13.dist-info/top_level.txt +1 -0
vcursor/__init__.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""VCursor SDK - Generate videos from text, images, and URLs using AI."""
|
|
2
|
+
|
|
3
|
+
from vcursor.client import VCursor, VCursorAsync
|
|
4
|
+
from vcursor.types import (
|
|
5
|
+
SubmitRequest,
|
|
6
|
+
SubmitResponse,
|
|
7
|
+
TaskProgress,
|
|
8
|
+
AgentSubmitRequest,
|
|
9
|
+
AgentProgress,
|
|
10
|
+
CategoryUsage,
|
|
11
|
+
ConcurrencyStatus,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
__version__ = "1.0.0"
|
|
15
|
+
__all__ = [
|
|
16
|
+
"VCursor",
|
|
17
|
+
"VCursorAsync",
|
|
18
|
+
"SubmitRequest",
|
|
19
|
+
"SubmitResponse",
|
|
20
|
+
"TaskProgress",
|
|
21
|
+
"AgentSubmitRequest",
|
|
22
|
+
"AgentProgress",
|
|
23
|
+
"CategoryUsage",
|
|
24
|
+
"ConcurrencyStatus",
|
|
25
|
+
]
|
vcursor/cli.py
ADDED
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
"""VCursor CLI - Python entry point.
|
|
2
|
+
|
|
3
|
+
Provides the same core commands as the Node.js CLI:
|
|
4
|
+
vcursor "a cat playing piano"
|
|
5
|
+
vcursor --agent "create a commercial"
|
|
6
|
+
vcursor login
|
|
7
|
+
vcursor whoami
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
import getpass
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import sys
|
|
17
|
+
import time
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
from vcursor.client import VCursor, VCursorError, ConcurrencyLimitError, _load_config, DEFAULT_SERVER
|
|
21
|
+
|
|
22
|
+
CONFIG_DIR = Path.home() / ".vcursor"
|
|
23
|
+
CONFIG_FILE = CONFIG_DIR / "config.json"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _save_config(config: dict) -> None:
|
|
27
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
28
|
+
CONFIG_FILE.write_text(json.dumps(config, indent=2) + "\n")
|
|
29
|
+
CONFIG_FILE.chmod(0o600)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _resolve_server(args: argparse.Namespace) -> str:
|
|
33
|
+
return getattr(args, "server", None) or os.environ.get("VCURSOR_SERVER") or _load_config().get("server") or DEFAULT_SERVER
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _resolve_api_key(args: argparse.Namespace) -> str | None:
|
|
37
|
+
return getattr(args, "api_key", None) or os.environ.get("VCURSOR_API_KEY") or _load_config().get("apiKey")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# ---------------------------------------------------------------------------
|
|
41
|
+
# Commands
|
|
42
|
+
# ---------------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
def cmd_login(args: argparse.Namespace) -> None:
|
|
45
|
+
server = _resolve_server(args)
|
|
46
|
+
api_key = getattr(args, "api_key", None)
|
|
47
|
+
if not api_key:
|
|
48
|
+
print(f"Server: {server}")
|
|
49
|
+
api_key = getpass.getpass("API Key: ")
|
|
50
|
+
if not api_key:
|
|
51
|
+
print("No API key provided.")
|
|
52
|
+
sys.exit(1)
|
|
53
|
+
|
|
54
|
+
# Validate
|
|
55
|
+
import httpx
|
|
56
|
+
try:
|
|
57
|
+
resp = httpx.post(
|
|
58
|
+
f"{server.rstrip('/')}/api/cli/validate",
|
|
59
|
+
headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
|
|
60
|
+
json={},
|
|
61
|
+
timeout=15,
|
|
62
|
+
)
|
|
63
|
+
if resp.status_code != 200:
|
|
64
|
+
print(f"Validation failed: {resp.text}")
|
|
65
|
+
sys.exit(1)
|
|
66
|
+
except Exception as e:
|
|
67
|
+
print(f"Connection error: {e}")
|
|
68
|
+
sys.exit(1)
|
|
69
|
+
|
|
70
|
+
config = _load_config()
|
|
71
|
+
config["server"] = server
|
|
72
|
+
config["apiKey"] = api_key
|
|
73
|
+
_save_config(config)
|
|
74
|
+
print("Logged in successfully.")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def cmd_logout(_args: argparse.Namespace) -> None:
|
|
78
|
+
config = _load_config()
|
|
79
|
+
config.pop("apiKey", None)
|
|
80
|
+
_save_config(config)
|
|
81
|
+
print("Logged out.")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def cmd_whoami(args: argparse.Namespace) -> None:
|
|
85
|
+
server = _resolve_server(args)
|
|
86
|
+
api_key = _resolve_api_key(args)
|
|
87
|
+
if not api_key:
|
|
88
|
+
print("Not authenticated. Run: vcursor login")
|
|
89
|
+
sys.exit(1)
|
|
90
|
+
|
|
91
|
+
import httpx
|
|
92
|
+
try:
|
|
93
|
+
resp = httpx.post(
|
|
94
|
+
f"{server.rstrip('/')}/api/cli/validate",
|
|
95
|
+
headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
|
|
96
|
+
json={},
|
|
97
|
+
timeout=15,
|
|
98
|
+
)
|
|
99
|
+
data = resp.json()
|
|
100
|
+
if resp.status_code == 200:
|
|
101
|
+
print(f"Authenticated as: {data.get('email', data.get('name', 'unknown'))}")
|
|
102
|
+
print(f"Server: {server}")
|
|
103
|
+
else:
|
|
104
|
+
print(f"Auth failed: {data.get('message', resp.status_code)}")
|
|
105
|
+
except Exception as e:
|
|
106
|
+
print(f"Connection error: {e}")
|
|
107
|
+
sys.exit(1)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def cmd_generate(args: argparse.Namespace) -> None:
|
|
111
|
+
api_key = _resolve_api_key(args)
|
|
112
|
+
if not api_key:
|
|
113
|
+
print("Not authenticated. Run: vcursor login")
|
|
114
|
+
sys.exit(1)
|
|
115
|
+
|
|
116
|
+
prompt = " ".join(args.inputs)
|
|
117
|
+
if not prompt:
|
|
118
|
+
print("No prompt provided.")
|
|
119
|
+
sys.exit(1)
|
|
120
|
+
|
|
121
|
+
max_conc = int(args.max_concurrency) if args.max_concurrency else None
|
|
122
|
+
client = VCursor(
|
|
123
|
+
api_key=api_key,
|
|
124
|
+
server=_resolve_server(args),
|
|
125
|
+
max_concurrency=max_conc,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
try:
|
|
129
|
+
if args.agent:
|
|
130
|
+
_run_agent(client, prompt, args)
|
|
131
|
+
else:
|
|
132
|
+
_run_standard(client, prompt, args)
|
|
133
|
+
except ConcurrencyLimitError as e:
|
|
134
|
+
window_label = "" if e.tier == "free" else "/hr"
|
|
135
|
+
print(f"Rate limit reached: {e.used}/{e.rate_limit} {e.category} requests{window_label} (tier: {e.tier})")
|
|
136
|
+
if e.tier == "free":
|
|
137
|
+
print("Upgrade your plan to get more requests.")
|
|
138
|
+
else:
|
|
139
|
+
print("Wait for your quota to reset, then try again.")
|
|
140
|
+
sys.exit(1)
|
|
141
|
+
except VCursorError as e:
|
|
142
|
+
print(f"Error ({e.status}): {e}")
|
|
143
|
+
sys.exit(1)
|
|
144
|
+
finally:
|
|
145
|
+
client.close()
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _run_standard(client: VCursor, prompt: str, args: argparse.Namespace) -> None:
|
|
149
|
+
print(f"Submitting: {prompt[:120]}{'...' if len(prompt) > 120 else ''}")
|
|
150
|
+
|
|
151
|
+
kwargs = {}
|
|
152
|
+
if args.mode:
|
|
153
|
+
kwargs["submission_mode"] = args.mode
|
|
154
|
+
|
|
155
|
+
resp = client.submit(prompt, **kwargs)
|
|
156
|
+
|
|
157
|
+
if resp.chat_response:
|
|
158
|
+
print(f"\n{resp.chat_response}")
|
|
159
|
+
return
|
|
160
|
+
if resp.plan:
|
|
161
|
+
print(f"\nPlan:\n{resp.plan}")
|
|
162
|
+
return
|
|
163
|
+
if not resp.task_id:
|
|
164
|
+
print("No task ID returned.")
|
|
165
|
+
sys.exit(1)
|
|
166
|
+
|
|
167
|
+
print(f"Task: {resp.task_id} (mode: {resp.mode or 'auto'})")
|
|
168
|
+
|
|
169
|
+
def _on_progress(p):
|
|
170
|
+
d = p.data
|
|
171
|
+
bar_len = 30
|
|
172
|
+
filled = int(bar_len * d.progress / 100)
|
|
173
|
+
bar = "█" * filled + "░" * (bar_len - filled)
|
|
174
|
+
eta = f" ~{int(d.remaining_process_time)}s" if d.remaining_process_time > 0 else ""
|
|
175
|
+
print(f"\r [{bar}] {d.progress:.0f}% {d.status}{eta} ", end="", flush=True)
|
|
176
|
+
|
|
177
|
+
result = client.wait_for_completion(resp.task_id, on_progress=_on_progress)
|
|
178
|
+
print()
|
|
179
|
+
|
|
180
|
+
if result.data.status == "completed":
|
|
181
|
+
print("Completed!")
|
|
182
|
+
if result.data.products.url:
|
|
183
|
+
print(f" Output: {result.data.products.url}")
|
|
184
|
+
if result.data.products.audio_url:
|
|
185
|
+
print(f" Audio: {result.data.products.audio_url}")
|
|
186
|
+
if result.data.products.refined_prompt:
|
|
187
|
+
print(f" Refined: {result.data.products.refined_prompt}")
|
|
188
|
+
else:
|
|
189
|
+
print(f"Task failed: {result.data.detail or result.data.status}")
|
|
190
|
+
sys.exit(1)
|
|
191
|
+
|
|
192
|
+
if args.json:
|
|
193
|
+
print(json.dumps({"code": result.code, "data": {"status": result.data.status}}, indent=2))
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _run_agent(client: VCursor, prompt: str, args: argparse.Namespace) -> None:
|
|
197
|
+
print(f"Submitting agent task: {prompt[:120]}{'...' if len(prompt) > 120 else ''}")
|
|
198
|
+
|
|
199
|
+
resp = client.submit_agent(prompt)
|
|
200
|
+
task_id = resp.get("task_id")
|
|
201
|
+
if not task_id:
|
|
202
|
+
print("No task ID returned.")
|
|
203
|
+
sys.exit(1)
|
|
204
|
+
|
|
205
|
+
print(f"Agent task: {task_id}")
|
|
206
|
+
|
|
207
|
+
def _on_progress(p):
|
|
208
|
+
bar_len = 30
|
|
209
|
+
filled = int(bar_len * p.progress / 100)
|
|
210
|
+
bar = "█" * filled + "░" * (bar_len - filled)
|
|
211
|
+
stage = p.current_stage or p.status
|
|
212
|
+
print(f"\r [{bar}] {p.progress:.0f}% {stage} ", end="", flush=True)
|
|
213
|
+
|
|
214
|
+
result = client.wait_for_agent_completion(task_id, on_progress=_on_progress)
|
|
215
|
+
print()
|
|
216
|
+
|
|
217
|
+
if result.status == "completed":
|
|
218
|
+
print("Agent task completed!")
|
|
219
|
+
if result.video_url:
|
|
220
|
+
print(f" Video: {result.video_url}")
|
|
221
|
+
if result.assets_url:
|
|
222
|
+
print(f" Assets: {result.assets_url}")
|
|
223
|
+
else:
|
|
224
|
+
print(f"Agent task failed: {result.error_message or result.status}")
|
|
225
|
+
sys.exit(1)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def cmd_concurrency(args: argparse.Namespace) -> None:
|
|
229
|
+
"""Show current concurrency status."""
|
|
230
|
+
api_key = _resolve_api_key(args)
|
|
231
|
+
if not api_key:
|
|
232
|
+
print("Not authenticated. Run: vcursor login")
|
|
233
|
+
sys.exit(1)
|
|
234
|
+
|
|
235
|
+
client = VCursor(api_key=api_key, server=_resolve_server(args))
|
|
236
|
+
try:
|
|
237
|
+
status = client.check_concurrency()
|
|
238
|
+
window_label = "lifetime" if status.tier == "free" else f"{status.window_hours}h window"
|
|
239
|
+
print(f"Tier: {status.tier} ({window_label})")
|
|
240
|
+
if status.summary:
|
|
241
|
+
s = status.summary["standard"]
|
|
242
|
+
a = status.summary["agent"]
|
|
243
|
+
print(f"Standard: {s.used}/{s.limit} used, {s.remaining} remaining")
|
|
244
|
+
print(f"Agent: {a.used}/{a.limit} used, {a.remaining} remaining")
|
|
245
|
+
if status.bypass_enabled:
|
|
246
|
+
print("Bypass: enabled (admin/test)")
|
|
247
|
+
except VCursorError as e:
|
|
248
|
+
print(f"Error: {e}")
|
|
249
|
+
sys.exit(1)
|
|
250
|
+
finally:
|
|
251
|
+
client.close()
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
# ---------------------------------------------------------------------------
|
|
255
|
+
# Argument parser
|
|
256
|
+
# ---------------------------------------------------------------------------
|
|
257
|
+
|
|
258
|
+
def main() -> None:
|
|
259
|
+
parser = argparse.ArgumentParser(
|
|
260
|
+
prog="vcursor",
|
|
261
|
+
description="VCursor CLI - Generate videos from text, images, and URLs using AI",
|
|
262
|
+
)
|
|
263
|
+
parser.add_argument("-v", "--version", action="version", version="vcursor 1.0.0")
|
|
264
|
+
|
|
265
|
+
sub = parser.add_subparsers(dest="command")
|
|
266
|
+
|
|
267
|
+
# login
|
|
268
|
+
login_p = sub.add_parser("login", help="Authenticate with your VCursor API key")
|
|
269
|
+
login_p.add_argument("--server", help="Server URL")
|
|
270
|
+
login_p.add_argument("--api-key", help="API key (non-interactive)")
|
|
271
|
+
|
|
272
|
+
# logout
|
|
273
|
+
sub.add_parser("logout", help="Remove stored API key")
|
|
274
|
+
|
|
275
|
+
# whoami
|
|
276
|
+
whoami_p = sub.add_parser("whoami", help="Show current authenticated user")
|
|
277
|
+
whoami_p.add_argument("--server", help="Server URL")
|
|
278
|
+
|
|
279
|
+
# concurrency
|
|
280
|
+
conc_p = sub.add_parser("concurrency", help="Show concurrency limit status")
|
|
281
|
+
conc_p.add_argument("--server", help="Server URL")
|
|
282
|
+
conc_p.add_argument("--api-key", help="API key")
|
|
283
|
+
|
|
284
|
+
# generate (default)
|
|
285
|
+
gen_p = sub.add_parser("generate", help="Generate video from prompt")
|
|
286
|
+
gen_p.add_argument("inputs", nargs="+", help="Text prompt, file paths, or URLs")
|
|
287
|
+
gen_p.add_argument("--agent", action="store_true", help="Use agent mode")
|
|
288
|
+
gen_p.add_argument("--mode", help="Force a specific mode")
|
|
289
|
+
gen_p.add_argument("--max-concurrency", help="Max concurrent tasks (client-side)")
|
|
290
|
+
gen_p.add_argument("--server", help="Server URL")
|
|
291
|
+
gen_p.add_argument("--api-key", help="API key")
|
|
292
|
+
gen_p.add_argument("--json", action="store_true", help="Output raw JSON")
|
|
293
|
+
|
|
294
|
+
args = parser.parse_args()
|
|
295
|
+
|
|
296
|
+
# If no subcommand but there are remaining args, treat as generate
|
|
297
|
+
if args.command is None:
|
|
298
|
+
# Re-parse with generate as implicit default
|
|
299
|
+
remaining = sys.argv[1:]
|
|
300
|
+
if remaining and not remaining[0].startswith("-"):
|
|
301
|
+
gen_args = gen_p.parse_args(remaining)
|
|
302
|
+
cmd_generate(gen_args)
|
|
303
|
+
return
|
|
304
|
+
parser.print_help()
|
|
305
|
+
return
|
|
306
|
+
|
|
307
|
+
if args.command == "login":
|
|
308
|
+
cmd_login(args)
|
|
309
|
+
elif args.command == "logout":
|
|
310
|
+
cmd_logout(args)
|
|
311
|
+
elif args.command == "whoami":
|
|
312
|
+
cmd_whoami(args)
|
|
313
|
+
elif args.command == "concurrency":
|
|
314
|
+
cmd_concurrency(args)
|
|
315
|
+
elif args.command == "generate":
|
|
316
|
+
cmd_generate(args)
|
|
317
|
+
else:
|
|
318
|
+
parser.print_help()
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
if __name__ == "__main__":
|
|
322
|
+
main()
|
vcursor/client.py
ADDED
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
"""VCursor API client for Python - sync and async."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import time
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, Callable
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
|
|
13
|
+
from vcursor.types import (
|
|
14
|
+
AgentProgress,
|
|
15
|
+
AgentSubmitRequest,
|
|
16
|
+
ConcurrencyStatus,
|
|
17
|
+
SubmitRequest,
|
|
18
|
+
SubmitResponse,
|
|
19
|
+
TaskProgress,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
DEFAULT_SERVER = "https://www.cli.vcursor.com"
|
|
23
|
+
CONFIG_PATH = Path.home() / ".vcursor" / "config.json"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class VCursorError(Exception):
|
|
27
|
+
"""Error returned by the VCursor API."""
|
|
28
|
+
|
|
29
|
+
def __init__(self, message: str, status: int, data: dict[str, Any] | None = None):
|
|
30
|
+
super().__init__(message)
|
|
31
|
+
self.status = status
|
|
32
|
+
self.data = data
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class ConcurrencyLimitError(VCursorError):
|
|
36
|
+
"""Raised when the user has exceeded their concurrency limit."""
|
|
37
|
+
|
|
38
|
+
def __init__(self, message: str, data: dict[str, Any] | None = None):
|
|
39
|
+
super().__init__(message, 429, data)
|
|
40
|
+
self.used: int = (data or {}).get("used", 0)
|
|
41
|
+
self.rate_limit: int = (data or {}).get("rateLimit", 0)
|
|
42
|
+
self.category: str = (data or {}).get("category", "standard")
|
|
43
|
+
self.tier: str = (data or {}).get("tier", "free")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _load_config() -> dict[str, Any]:
|
|
47
|
+
"""Load config from ~/.vcursor/config.json."""
|
|
48
|
+
try:
|
|
49
|
+
return json.loads(CONFIG_PATH.read_text())
|
|
50
|
+
except (FileNotFoundError, json.JSONDecodeError):
|
|
51
|
+
return {}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _resolve_server(server: str | None = None) -> str:
|
|
55
|
+
return server or os.environ.get("VCURSOR_SERVER") or _load_config().get("server") or DEFAULT_SERVER
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _resolve_api_key(api_key: str | None = None) -> str | None:
|
|
59
|
+
return api_key or os.environ.get("VCURSOR_API_KEY") or _load_config().get("apiKey")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# ---------------------------------------------------------------------------
|
|
63
|
+
# Synchronous client
|
|
64
|
+
# ---------------------------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
class VCursor:
|
|
67
|
+
"""Synchronous VCursor API client.
|
|
68
|
+
|
|
69
|
+
Usage::
|
|
70
|
+
|
|
71
|
+
from vcursor import VCursor
|
|
72
|
+
|
|
73
|
+
client = VCursor(api_key="vk-...")
|
|
74
|
+
resp = client.submit("a cat playing piano")
|
|
75
|
+
result = client.wait_for_completion(resp.task_id)
|
|
76
|
+
print(result.data.products.url)
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
def __init__(
|
|
80
|
+
self,
|
|
81
|
+
api_key: str | None = None,
|
|
82
|
+
server: str | None = None,
|
|
83
|
+
max_concurrency: int | None = None,
|
|
84
|
+
timeout: float = 60.0,
|
|
85
|
+
):
|
|
86
|
+
self.server = _resolve_server(server).rstrip("/")
|
|
87
|
+
self.api_key = _resolve_api_key(api_key)
|
|
88
|
+
self.max_concurrency = max_concurrency
|
|
89
|
+
self._client = httpx.Client(timeout=timeout)
|
|
90
|
+
|
|
91
|
+
def _headers(self) -> dict[str, str]:
|
|
92
|
+
h: dict[str, str] = {"Content-Type": "application/json"}
|
|
93
|
+
if self.api_key:
|
|
94
|
+
h["Authorization"] = f"Bearer {self.api_key}"
|
|
95
|
+
return h
|
|
96
|
+
|
|
97
|
+
def _request(self, method: str, path: str, body: dict[str, Any] | None = None) -> Any:
|
|
98
|
+
url = f"{self.server}{path}"
|
|
99
|
+
resp = self._client.request(method, url, headers=self._headers(), json=body)
|
|
100
|
+
data = resp.json()
|
|
101
|
+
if not resp.is_success:
|
|
102
|
+
msg = data.get("message") or data.get("error") or f"HTTP {resp.status_code}"
|
|
103
|
+
if resp.status_code == 429:
|
|
104
|
+
raise ConcurrencyLimitError(msg, data)
|
|
105
|
+
raise VCursorError(msg, resp.status_code, data)
|
|
106
|
+
return data
|
|
107
|
+
|
|
108
|
+
# --- Concurrency ---
|
|
109
|
+
|
|
110
|
+
def check_concurrency(self, category: str = "standard") -> ConcurrencyStatus:
|
|
111
|
+
"""Check current rate limit usage and limits.
|
|
112
|
+
|
|
113
|
+
Args:
|
|
114
|
+
category: 'standard' or 'agent'.
|
|
115
|
+
"""
|
|
116
|
+
data = self._request("POST", "/api/concurrency/check", {"category": category})
|
|
117
|
+
return ConcurrencyStatus.from_dict(data)
|
|
118
|
+
|
|
119
|
+
def _enforce_concurrency(self, category: str = "standard") -> None:
|
|
120
|
+
"""Pre-flight rate limit guard. Raises ConcurrencyLimitError if over limit."""
|
|
121
|
+
status = self.check_concurrency(category)
|
|
122
|
+
effective_limit = self.max_concurrency if self.max_concurrency is not None else status.limit
|
|
123
|
+
if not status.bypass_enabled and status.used >= effective_limit:
|
|
124
|
+
window_label = "" if status.tier == "free" else "/hr"
|
|
125
|
+
raise ConcurrencyLimitError(
|
|
126
|
+
f"Rate limit reached: {status.used}/{effective_limit} {category} requests{window_label}",
|
|
127
|
+
{
|
|
128
|
+
"used": status.used,
|
|
129
|
+
"rateLimit": effective_limit,
|
|
130
|
+
"category": category,
|
|
131
|
+
"tier": status.tier,
|
|
132
|
+
},
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
# --- Standard mode ---
|
|
136
|
+
|
|
137
|
+
def submit(self, prompt: str, **kwargs: Any) -> SubmitResponse:
|
|
138
|
+
"""Submit a standard video generation task.
|
|
139
|
+
|
|
140
|
+
Args:
|
|
141
|
+
prompt: Text prompt.
|
|
142
|
+
**kwargs: Additional fields for SubmitRequest (media_keys, reference_urls, etc.).
|
|
143
|
+
|
|
144
|
+
Returns:
|
|
145
|
+
SubmitResponse with task_id for polling.
|
|
146
|
+
"""
|
|
147
|
+
self._enforce_concurrency("standard")
|
|
148
|
+
req = SubmitRequest(prompt=prompt, **kwargs)
|
|
149
|
+
data = self._request("POST", "/api/task/submit_anything", req.to_dict())
|
|
150
|
+
return SubmitResponse.from_dict(data)
|
|
151
|
+
|
|
152
|
+
def get_progress(self, task_id: str) -> TaskProgress:
|
|
153
|
+
"""Get progress for a standard task."""
|
|
154
|
+
data = self._request("GET", f"/api/task/progress/{task_id}")
|
|
155
|
+
return TaskProgress.from_dict(data)
|
|
156
|
+
|
|
157
|
+
def cancel_task(self, task_id: str) -> dict[str, str]:
|
|
158
|
+
"""Cancel a running task."""
|
|
159
|
+
return self._request("GET", f"/api/task/abort/{task_id}")
|
|
160
|
+
|
|
161
|
+
def wait_for_completion(
|
|
162
|
+
self,
|
|
163
|
+
task_id: str,
|
|
164
|
+
poll_interval: float = 2.0,
|
|
165
|
+
on_progress: Callable[[TaskProgress], None] | None = None,
|
|
166
|
+
) -> TaskProgress:
|
|
167
|
+
"""Poll until a standard task completes or fails.
|
|
168
|
+
|
|
169
|
+
Args:
|
|
170
|
+
task_id: Task ID to poll.
|
|
171
|
+
poll_interval: Seconds between polls.
|
|
172
|
+
on_progress: Optional callback invoked on each poll.
|
|
173
|
+
|
|
174
|
+
Returns:
|
|
175
|
+
Final TaskProgress.
|
|
176
|
+
"""
|
|
177
|
+
while True:
|
|
178
|
+
progress = self.get_progress(task_id)
|
|
179
|
+
if on_progress:
|
|
180
|
+
on_progress(progress)
|
|
181
|
+
if progress.data.status in ("completed", "failed"):
|
|
182
|
+
return progress
|
|
183
|
+
time.sleep(poll_interval)
|
|
184
|
+
|
|
185
|
+
# --- Agent mode ---
|
|
186
|
+
|
|
187
|
+
def submit_agent(self, message: str, **kwargs: Any) -> dict[str, str]:
|
|
188
|
+
"""Submit an agent mode task.
|
|
189
|
+
|
|
190
|
+
Args:
|
|
191
|
+
message: Agent prompt.
|
|
192
|
+
**kwargs: Additional fields for AgentSubmitRequest.
|
|
193
|
+
|
|
194
|
+
Returns:
|
|
195
|
+
Dict with task_id and status.
|
|
196
|
+
"""
|
|
197
|
+
self._enforce_concurrency("agent")
|
|
198
|
+
req = AgentSubmitRequest(message=message, **kwargs)
|
|
199
|
+
return self._request("POST", "/api/direct/submit", req.to_dict())
|
|
200
|
+
|
|
201
|
+
def get_agent_progress(self, task_id: str) -> AgentProgress:
|
|
202
|
+
"""Get progress for an agent task."""
|
|
203
|
+
data = self._request("GET", f"/api/direct/progress/{task_id}")
|
|
204
|
+
return AgentProgress.from_dict(data)
|
|
205
|
+
|
|
206
|
+
def cancel_agent_task(self, task_id: str) -> dict[str, str]:
|
|
207
|
+
"""Cancel an agent task."""
|
|
208
|
+
return self._request("DELETE", f"/api/direct/cancel/{task_id}")
|
|
209
|
+
|
|
210
|
+
def wait_for_agent_completion(
|
|
211
|
+
self,
|
|
212
|
+
task_id: str,
|
|
213
|
+
poll_interval: float = 3.0,
|
|
214
|
+
on_progress: Callable[[AgentProgress], None] | None = None,
|
|
215
|
+
) -> AgentProgress:
|
|
216
|
+
"""Poll until an agent task completes or fails."""
|
|
217
|
+
while True:
|
|
218
|
+
progress = self.get_agent_progress(task_id)
|
|
219
|
+
if on_progress:
|
|
220
|
+
on_progress(progress)
|
|
221
|
+
if progress.status in ("completed", "error"):
|
|
222
|
+
return progress
|
|
223
|
+
time.sleep(poll_interval)
|
|
224
|
+
|
|
225
|
+
def close(self) -> None:
|
|
226
|
+
self._client.close()
|
|
227
|
+
|
|
228
|
+
def __enter__(self) -> VCursor:
|
|
229
|
+
return self
|
|
230
|
+
|
|
231
|
+
def __exit__(self, *args: Any) -> None:
|
|
232
|
+
self.close()
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
# ---------------------------------------------------------------------------
|
|
236
|
+
# Async client
|
|
237
|
+
# ---------------------------------------------------------------------------
|
|
238
|
+
|
|
239
|
+
class VCursorAsync:
|
|
240
|
+
"""Async VCursor API client.
|
|
241
|
+
|
|
242
|
+
Usage::
|
|
243
|
+
|
|
244
|
+
import asyncio
|
|
245
|
+
from vcursor import VCursorAsync
|
|
246
|
+
|
|
247
|
+
async def main():
|
|
248
|
+
async with VCursorAsync(api_key="vk-...") as client:
|
|
249
|
+
resp = await client.submit("a cat playing piano")
|
|
250
|
+
result = await client.wait_for_completion(resp.task_id)
|
|
251
|
+
print(result.data.products.url)
|
|
252
|
+
|
|
253
|
+
asyncio.run(main())
|
|
254
|
+
"""
|
|
255
|
+
|
|
256
|
+
def __init__(
|
|
257
|
+
self,
|
|
258
|
+
api_key: str | None = None,
|
|
259
|
+
server: str | None = None,
|
|
260
|
+
max_concurrency: int | None = None,
|
|
261
|
+
timeout: float = 60.0,
|
|
262
|
+
):
|
|
263
|
+
self.server = _resolve_server(server).rstrip("/")
|
|
264
|
+
self.api_key = _resolve_api_key(api_key)
|
|
265
|
+
self.max_concurrency = max_concurrency
|
|
266
|
+
self._client = httpx.AsyncClient(timeout=timeout)
|
|
267
|
+
|
|
268
|
+
def _headers(self) -> dict[str, str]:
|
|
269
|
+
h: dict[str, str] = {"Content-Type": "application/json"}
|
|
270
|
+
if self.api_key:
|
|
271
|
+
h["Authorization"] = f"Bearer {self.api_key}"
|
|
272
|
+
return h
|
|
273
|
+
|
|
274
|
+
async def _request(self, method: str, path: str, body: dict[str, Any] | None = None) -> Any:
|
|
275
|
+
url = f"{self.server}{path}"
|
|
276
|
+
resp = await self._client.request(method, url, headers=self._headers(), json=body)
|
|
277
|
+
data = resp.json()
|
|
278
|
+
if not resp.is_success:
|
|
279
|
+
msg = data.get("message") or data.get("error") or f"HTTP {resp.status_code}"
|
|
280
|
+
if resp.status_code == 429:
|
|
281
|
+
raise ConcurrencyLimitError(msg, data)
|
|
282
|
+
raise VCursorError(msg, resp.status_code, data)
|
|
283
|
+
return data
|
|
284
|
+
|
|
285
|
+
# --- Concurrency ---
|
|
286
|
+
|
|
287
|
+
async def check_concurrency(self, category: str = "standard") -> ConcurrencyStatus:
|
|
288
|
+
data = await self._request("POST", "/api/concurrency/check", {"category": category})
|
|
289
|
+
return ConcurrencyStatus.from_dict(data)
|
|
290
|
+
|
|
291
|
+
async def _enforce_concurrency(self, category: str = "standard") -> None:
|
|
292
|
+
status = await self.check_concurrency(category)
|
|
293
|
+
effective_limit = self.max_concurrency if self.max_concurrency is not None else status.limit
|
|
294
|
+
if not status.bypass_enabled and status.used >= effective_limit:
|
|
295
|
+
window_label = "" if status.tier == "free" else "/hr"
|
|
296
|
+
raise ConcurrencyLimitError(
|
|
297
|
+
f"Rate limit reached: {status.used}/{effective_limit} {category} requests{window_label}",
|
|
298
|
+
{
|
|
299
|
+
"used": status.used,
|
|
300
|
+
"rateLimit": effective_limit,
|
|
301
|
+
"category": category,
|
|
302
|
+
"tier": status.tier,
|
|
303
|
+
},
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
# --- Standard mode ---
|
|
307
|
+
|
|
308
|
+
async def submit(self, prompt: str, **kwargs: Any) -> SubmitResponse:
|
|
309
|
+
await self._enforce_concurrency("standard")
|
|
310
|
+
req = SubmitRequest(prompt=prompt, **kwargs)
|
|
311
|
+
data = await self._request("POST", "/api/task/submit_anything", req.to_dict())
|
|
312
|
+
return SubmitResponse.from_dict(data)
|
|
313
|
+
|
|
314
|
+
async def get_progress(self, task_id: str) -> TaskProgress:
|
|
315
|
+
data = await self._request("GET", f"/api/task/progress/{task_id}")
|
|
316
|
+
return TaskProgress.from_dict(data)
|
|
317
|
+
|
|
318
|
+
async def cancel_task(self, task_id: str) -> dict[str, str]:
|
|
319
|
+
return await self._request("GET", f"/api/task/abort/{task_id}")
|
|
320
|
+
|
|
321
|
+
async def wait_for_completion(
|
|
322
|
+
self,
|
|
323
|
+
task_id: str,
|
|
324
|
+
poll_interval: float = 2.0,
|
|
325
|
+
on_progress: Callable[[TaskProgress], None] | None = None,
|
|
326
|
+
) -> TaskProgress:
|
|
327
|
+
import asyncio
|
|
328
|
+
while True:
|
|
329
|
+
progress = await self.get_progress(task_id)
|
|
330
|
+
if on_progress:
|
|
331
|
+
on_progress(progress)
|
|
332
|
+
if progress.data.status in ("completed", "failed"):
|
|
333
|
+
return progress
|
|
334
|
+
await asyncio.sleep(poll_interval)
|
|
335
|
+
|
|
336
|
+
# --- Agent mode ---
|
|
337
|
+
|
|
338
|
+
async def submit_agent(self, message: str, **kwargs: Any) -> dict[str, str]:
|
|
339
|
+
await self._enforce_concurrency("agent")
|
|
340
|
+
req = AgentSubmitRequest(message=message, **kwargs)
|
|
341
|
+
return await self._request("POST", "/api/direct/submit", req.to_dict())
|
|
342
|
+
|
|
343
|
+
async def get_agent_progress(self, task_id: str) -> AgentProgress:
|
|
344
|
+
data = await self._request("GET", f"/api/direct/progress/{task_id}")
|
|
345
|
+
return AgentProgress.from_dict(data)
|
|
346
|
+
|
|
347
|
+
async def cancel_agent_task(self, task_id: str) -> dict[str, str]:
|
|
348
|
+
return await self._request("DELETE", f"/api/direct/cancel/{task_id}")
|
|
349
|
+
|
|
350
|
+
async def wait_for_agent_completion(
|
|
351
|
+
self,
|
|
352
|
+
task_id: str,
|
|
353
|
+
poll_interval: float = 3.0,
|
|
354
|
+
on_progress: Callable[[AgentProgress], None] | None = None,
|
|
355
|
+
) -> AgentProgress:
|
|
356
|
+
import asyncio
|
|
357
|
+
while True:
|
|
358
|
+
progress = await self.get_agent_progress(task_id)
|
|
359
|
+
if on_progress:
|
|
360
|
+
on_progress(progress)
|
|
361
|
+
if progress.status in ("completed", "error"):
|
|
362
|
+
return progress
|
|
363
|
+
await asyncio.sleep(poll_interval)
|
|
364
|
+
|
|
365
|
+
async def close(self) -> None:
|
|
366
|
+
await self._client.aclose()
|
|
367
|
+
|
|
368
|
+
async def __aenter__(self) -> VCursorAsync:
|
|
369
|
+
return self
|
|
370
|
+
|
|
371
|
+
async def __aexit__(self, *args: Any) -> None:
|
|
372
|
+
await self.close()
|
vcursor/types.py
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"""Type definitions for the VCursor SDK."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Any, Optional
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class SubmitRequest:
|
|
11
|
+
"""Request to submit a standard video generation task."""
|
|
12
|
+
prompt: str
|
|
13
|
+
media_keys: list[str] | None = None
|
|
14
|
+
reference_urls: list[str] | None = None
|
|
15
|
+
submission_mode: str | None = None # 'plan' | 'execute' | 'ask'
|
|
16
|
+
plan_id: str | None = None
|
|
17
|
+
plan_data: dict[str, Any] | None = None
|
|
18
|
+
|
|
19
|
+
def to_dict(self) -> dict[str, Any]:
|
|
20
|
+
d: dict[str, Any] = {"prompt": self.prompt}
|
|
21
|
+
if self.media_keys:
|
|
22
|
+
d["media_keys"] = self.media_keys
|
|
23
|
+
if self.reference_urls:
|
|
24
|
+
d["reference_urls"] = self.reference_urls
|
|
25
|
+
if self.submission_mode:
|
|
26
|
+
d["submission_mode"] = self.submission_mode
|
|
27
|
+
if self.plan_id:
|
|
28
|
+
d["plan_id"] = self.plan_id
|
|
29
|
+
if self.plan_data:
|
|
30
|
+
d["plan_data"] = self.plan_data
|
|
31
|
+
return d
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class SubmitResponse:
|
|
36
|
+
"""Response from task submission."""
|
|
37
|
+
code: int
|
|
38
|
+
message: str
|
|
39
|
+
task_id: str | None = None
|
|
40
|
+
mode: str | None = None
|
|
41
|
+
chat_response: str | None = None
|
|
42
|
+
plan: str | None = None
|
|
43
|
+
plan_id: str | None = None
|
|
44
|
+
|
|
45
|
+
@classmethod
|
|
46
|
+
def from_dict(cls, data: dict[str, Any]) -> SubmitResponse:
|
|
47
|
+
return cls(
|
|
48
|
+
code=data.get("code", 0),
|
|
49
|
+
message=data.get("message", ""),
|
|
50
|
+
task_id=data.get("task_id"),
|
|
51
|
+
mode=data.get("mode"),
|
|
52
|
+
chat_response=data.get("chat_response"),
|
|
53
|
+
plan=data.get("plan"),
|
|
54
|
+
plan_id=data.get("plan_id"),
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass
|
|
59
|
+
class TaskProducts:
|
|
60
|
+
"""Products from a completed task."""
|
|
61
|
+
url: str | None = None
|
|
62
|
+
thumbnail_url: str | None = None
|
|
63
|
+
audio_url: str | None = None
|
|
64
|
+
upload_completed: bool = False
|
|
65
|
+
refined_prompt: str | None = None
|
|
66
|
+
aspect_ratio: str | None = None
|
|
67
|
+
|
|
68
|
+
@classmethod
|
|
69
|
+
def from_dict(cls, data: dict[str, Any]) -> TaskProducts:
|
|
70
|
+
return cls(
|
|
71
|
+
url=data.get("url"),
|
|
72
|
+
thumbnail_url=data.get("thumbnail_url"),
|
|
73
|
+
audio_url=data.get("audio_url"),
|
|
74
|
+
upload_completed=data.get("upload_completed", False),
|
|
75
|
+
refined_prompt=data.get("refined_prompt"),
|
|
76
|
+
aspect_ratio=data.get("aspect_ratio"),
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass
|
|
81
|
+
class TaskProgressData:
|
|
82
|
+
"""Inner data of task progress."""
|
|
83
|
+
task_id: str
|
|
84
|
+
status: str # 'pending' | 'processing' | 'completed' | 'failed' | 'temporal_error'
|
|
85
|
+
progress: float = 0.0
|
|
86
|
+
queuing_time: float = 0.0
|
|
87
|
+
remaining_process_time: float = 0.0
|
|
88
|
+
total_process_time: float = 0.0
|
|
89
|
+
products: TaskProducts = field(default_factory=TaskProducts)
|
|
90
|
+
detail: str | None = None
|
|
91
|
+
|
|
92
|
+
@classmethod
|
|
93
|
+
def from_dict(cls, data: dict[str, Any]) -> TaskProgressData:
|
|
94
|
+
return cls(
|
|
95
|
+
task_id=data.get("task_id", ""),
|
|
96
|
+
status=data.get("status", "pending"),
|
|
97
|
+
progress=data.get("progress", 0.0),
|
|
98
|
+
queuing_time=data.get("queuing_time", 0.0),
|
|
99
|
+
remaining_process_time=data.get("remaining_process_time", 0.0),
|
|
100
|
+
total_process_time=data.get("total_process_time", 0.0),
|
|
101
|
+
products=TaskProducts.from_dict(data.get("products", {})),
|
|
102
|
+
detail=data.get("detail"),
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@dataclass
|
|
107
|
+
class TaskProgress:
|
|
108
|
+
"""Full task progress response."""
|
|
109
|
+
code: int
|
|
110
|
+
message: str
|
|
111
|
+
data: TaskProgressData = field(default_factory=lambda: TaskProgressData(task_id="", status="pending"))
|
|
112
|
+
|
|
113
|
+
@classmethod
|
|
114
|
+
def from_dict(cls, raw: dict[str, Any]) -> TaskProgress:
|
|
115
|
+
return cls(
|
|
116
|
+
code=raw.get("code", 0),
|
|
117
|
+
message=raw.get("message", ""),
|
|
118
|
+
data=TaskProgressData.from_dict(raw.get("data", {})),
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@dataclass
|
|
123
|
+
class AgentSubmitRequest:
|
|
124
|
+
"""Request to submit an agent mode task."""
|
|
125
|
+
message: str
|
|
126
|
+
duration: str | None = None
|
|
127
|
+
aspect_ratio: str | None = None
|
|
128
|
+
model_name: str | None = None
|
|
129
|
+
voiceover: str | None = None
|
|
130
|
+
subtitles: str | None = None
|
|
131
|
+
bgm: str | None = None
|
|
132
|
+
sound_effects: str | None = None
|
|
133
|
+
visual_style: str | None = None
|
|
134
|
+
|
|
135
|
+
def to_dict(self) -> dict[str, Any]:
|
|
136
|
+
d: dict[str, Any] = {"message": self.message}
|
|
137
|
+
for key in ("duration", "aspect_ratio", "model_name", "voiceover",
|
|
138
|
+
"subtitles", "bgm", "sound_effects", "visual_style"):
|
|
139
|
+
val = getattr(self, key)
|
|
140
|
+
if val is not None:
|
|
141
|
+
d[key] = val
|
|
142
|
+
return d
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@dataclass
|
|
146
|
+
class AgentProgress:
|
|
147
|
+
"""Agent task progress."""
|
|
148
|
+
task_id: str
|
|
149
|
+
status: str # 'pending' | 'processing' | 'completed' | 'error'
|
|
150
|
+
progress: float = 0.0
|
|
151
|
+
current_stage: str | None = None
|
|
152
|
+
estimated_time_remaining: float | None = None
|
|
153
|
+
video_url: str | None = None
|
|
154
|
+
assets_url: str | None = None
|
|
155
|
+
error_message: str | None = None
|
|
156
|
+
|
|
157
|
+
@classmethod
|
|
158
|
+
def from_dict(cls, data: dict[str, Any]) -> AgentProgress:
|
|
159
|
+
return cls(
|
|
160
|
+
task_id=data.get("task_id", ""),
|
|
161
|
+
status=data.get("status", "pending"),
|
|
162
|
+
progress=data.get("progress", 0.0),
|
|
163
|
+
current_stage=data.get("current_stage"),
|
|
164
|
+
estimated_time_remaining=data.get("estimated_time_remaining"),
|
|
165
|
+
video_url=data.get("video_url"),
|
|
166
|
+
assets_url=data.get("assets_url"),
|
|
167
|
+
error_message=data.get("error_message"),
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
@dataclass
|
|
172
|
+
class CategoryUsage:
|
|
173
|
+
"""Usage for a single task category (standard or agent)."""
|
|
174
|
+
used: int
|
|
175
|
+
limit: int | float
|
|
176
|
+
remaining: int | float
|
|
177
|
+
|
|
178
|
+
@classmethod
|
|
179
|
+
def from_dict(cls, data: dict[str, Any]) -> CategoryUsage:
|
|
180
|
+
return cls(
|
|
181
|
+
used=data.get("used", 0),
|
|
182
|
+
limit=data.get("limit", 0),
|
|
183
|
+
remaining=data.get("remaining", 0),
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
@dataclass
|
|
188
|
+
class ConcurrencyStatus:
|
|
189
|
+
"""Rate limit status for the current user.
|
|
190
|
+
|
|
191
|
+
Includes separate quotas for standard and agent requests.
|
|
192
|
+
"""
|
|
193
|
+
allowed: bool
|
|
194
|
+
limit: int | float
|
|
195
|
+
used: int
|
|
196
|
+
remaining: int | float
|
|
197
|
+
category: str # 'standard' | 'agent'
|
|
198
|
+
tier: str
|
|
199
|
+
bypass_enabled: bool = False
|
|
200
|
+
window_hours: int = 1
|
|
201
|
+
reset_at: str | None = None
|
|
202
|
+
summary: dict[str, CategoryUsage] | None = None
|
|
203
|
+
|
|
204
|
+
@classmethod
|
|
205
|
+
def from_dict(cls, data: dict[str, Any]) -> ConcurrencyStatus:
|
|
206
|
+
summary_raw = data.get("summary", {})
|
|
207
|
+
summary = {
|
|
208
|
+
"standard": CategoryUsage.from_dict(summary_raw.get("standard", {})),
|
|
209
|
+
"agent": CategoryUsage.from_dict(summary_raw.get("agent", {})),
|
|
210
|
+
} if summary_raw else None
|
|
211
|
+
return cls(
|
|
212
|
+
allowed=data.get("allowed", False),
|
|
213
|
+
limit=data.get("limit", 0),
|
|
214
|
+
used=data.get("used", 0),
|
|
215
|
+
remaining=data.get("remaining", 0),
|
|
216
|
+
category=data.get("category", "standard"),
|
|
217
|
+
tier=data.get("tier", "free"),
|
|
218
|
+
bypass_enabled=data.get("bypassEnabled", False),
|
|
219
|
+
window_hours=data.get("windowHours", 1),
|
|
220
|
+
reset_at=data.get("resetAt"),
|
|
221
|
+
summary=summary,
|
|
222
|
+
)
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: vcursor-cli
|
|
3
|
+
Version: 1.0.13
|
|
4
|
+
Summary: VCursor SDK & CLI - Generate videos from text, images, and URLs using AI
|
|
5
|
+
Author-email: Julius <support@vcursor.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://cli.vcursor.com
|
|
8
|
+
Project-URL: Repository, https://github.com/JThh/vcursor
|
|
9
|
+
Project-URL: Documentation, https://cli.vcursor.com/docs
|
|
10
|
+
Keywords: video,ai,generation,cli,vcursor,sdk
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Topic :: Multimedia :: Video
|
|
21
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
22
|
+
Requires-Python: >=3.9
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
Requires-Dist: httpx>=0.25.0
|
|
25
|
+
Provides-Extra: dev
|
|
26
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
27
|
+
Requires-Dist: pytest-asyncio>=0.21; extra == "dev"
|
|
28
|
+
|
|
29
|
+
# VCursor Python SDK & CLI
|
|
30
|
+
|
|
31
|
+
Generate videos from text, images, and URLs using AI.
|
|
32
|
+
|
|
33
|
+
## Installation
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
pip install vcursor
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Quick Start
|
|
40
|
+
|
|
41
|
+
### As a library (embedded in code)
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
from vcursor import VCursor
|
|
45
|
+
|
|
46
|
+
client = VCursor(api_key="vk-...")
|
|
47
|
+
|
|
48
|
+
# Submit a video generation task
|
|
49
|
+
resp = client.submit("a cat playing piano in a jazz club")
|
|
50
|
+
|
|
51
|
+
# Wait for completion with progress callback
|
|
52
|
+
result = client.wait_for_completion(
|
|
53
|
+
resp.task_id,
|
|
54
|
+
on_progress=lambda p: print(f"{p.data.progress}%")
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
print(result.data.products.url)
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
### Async usage
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
import asyncio
|
|
64
|
+
from vcursor import VCursorAsync
|
|
65
|
+
|
|
66
|
+
async def main():
|
|
67
|
+
async with VCursorAsync(api_key="vk-...") as client:
|
|
68
|
+
resp = await client.submit("sunset timelapse over mountains")
|
|
69
|
+
result = await client.wait_for_completion(resp.task_id)
|
|
70
|
+
print(result.data.products.url)
|
|
71
|
+
|
|
72
|
+
asyncio.run(main())
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
### Concurrency limiting
|
|
76
|
+
|
|
77
|
+
The server enforces per-user rate limits based on subscription tier:
|
|
78
|
+
|
|
79
|
+
| Tier | Standard Requests | Agent Requests | Window |
|
|
80
|
+
|------|-------------------|----------------|-----------|
|
|
81
|
+
| Free | 3 | 1 | Lifetime |
|
|
82
|
+
| Pro | 30/hr | 5/hr | Rolling 1h|
|
|
83
|
+
| Plus | 120/hr | 15/hr | Rolling 1h|
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
# Check current rate limit status
|
|
87
|
+
status = client.check_concurrency("standard")
|
|
88
|
+
print(f"Standard: {status.summary['standard'].used}/{status.summary['standard'].limit}")
|
|
89
|
+
print(f"Agent: {status.summary['agent'].used}/{status.summary['agent'].limit}")
|
|
90
|
+
|
|
91
|
+
# Client-side limit (capped at server limit)
|
|
92
|
+
client = VCursor(api_key="vk-...", max_concurrency=10)
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
### As a CLI
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
# Authenticate
|
|
99
|
+
vcursor login
|
|
100
|
+
|
|
101
|
+
# Generate a video
|
|
102
|
+
vcursor generate "a cat playing piano"
|
|
103
|
+
|
|
104
|
+
# Agent mode
|
|
105
|
+
vcursor generate --agent "create a 30s commercial"
|
|
106
|
+
|
|
107
|
+
# Check concurrency status
|
|
108
|
+
vcursor concurrency
|
|
109
|
+
|
|
110
|
+
# With concurrency limit
|
|
111
|
+
vcursor generate --max-concurrency 2 "sunset timelapse"
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
## API Reference
|
|
115
|
+
|
|
116
|
+
### `VCursor` / `VCursorAsync`
|
|
117
|
+
|
|
118
|
+
| Method | Description |
|
|
119
|
+
|---------------------------|----------------------------------------|
|
|
120
|
+
| `submit(prompt, **kw)` | Submit a standard video generation task |
|
|
121
|
+
| `get_progress(task_id)` | Get task progress |
|
|
122
|
+
| `wait_for_completion()` | Poll until task completes |
|
|
123
|
+
| `cancel_task(task_id)` | Cancel a running task |
|
|
124
|
+
| `submit_agent(message)` | Submit an agent mode task |
|
|
125
|
+
| `get_agent_progress()` | Get agent task progress |
|
|
126
|
+
| `wait_for_agent_completion()` | Poll until agent task completes |
|
|
127
|
+
| `check_concurrency()` | Check concurrency limit status |
|
|
128
|
+
|
|
129
|
+
## Configuration
|
|
130
|
+
|
|
131
|
+
Config is stored in `~/.vcursor/config.json` (shared with the Node.js CLI).
|
|
132
|
+
|
|
133
|
+
Environment variables:
|
|
134
|
+
- `VCURSOR_API_KEY` - API key
|
|
135
|
+
- `VCURSOR_SERVER` - Server URL
|
|
136
|
+
|
|
137
|
+
## License
|
|
138
|
+
|
|
139
|
+
MIT
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
vcursor/__init__.py,sha256=tk56HRKYr179Gt5CdYS7W3ECKAnleSq845XCXFNTXJo,524
|
|
2
|
+
vcursor/cli.py,sha256=QM1Zt73b_ROfvYnfST-1p7lCoaTocoCb0ONkJedQPSI,10618
|
|
3
|
+
vcursor/client.py,sha256=fxkAD0La3__iFWgjuXZIjBwBBUqfwHt0R5uNcUK7jwE,13218
|
|
4
|
+
vcursor/types.py,sha256=FCKUw-EcQGryf3KUoCMODNK8pjfMWILtBqn-Kcb1zik,7082
|
|
5
|
+
vcursor_cli-1.0.13.dist-info/METADATA,sha256=swVzMJOJomSlGcV0cRCowmat8WTjghYGTGENcTisaLg,4079
|
|
6
|
+
vcursor_cli-1.0.13.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
|
|
7
|
+
vcursor_cli-1.0.13.dist-info/entry_points.txt,sha256=ZKxaj1P8-RJJ9OrxgeNDdEaj1nzWG22bKwMr8Qar5pY,45
|
|
8
|
+
vcursor_cli-1.0.13.dist-info/top_level.txt,sha256=-CtKsgji6iMgfMKcDMmkMtb4msDBj0xG0i0Dmywccfs,8
|
|
9
|
+
vcursor_cli-1.0.13.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
vcursor
|