Skip to content
Open
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
9 changes: 6 additions & 3 deletions .github/workflows/docker.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ on:

env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}

jobs:
build-and-push:
Expand Down Expand Up @@ -40,11 +39,15 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- name: Normalize image name
id: image
run: echo "name=${GITHUB_REPOSITORY,,}" >> "$GITHUB_OUTPUT"

- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
images: ${{ env.REGISTRY }}/${{ steps.image.outputs.name }}
tags: |
type=raw,value=latest,enable=${{ github.ref_type == 'branch' }}
type=ref,event=tag
Expand All @@ -60,4 +63,4 @@ jobs:
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
pull: true
pull: true
6 changes: 3 additions & 3 deletions .github/workflows/security.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ jobs:
- uses: actions/checkout@v4
with:
fetch-depth: 0

# 针对 PR:使用原生逻辑
- name: Setup PR commits & Run Gitleaks
if: github.event_name == 'pull_request'
Expand All @@ -29,7 +29,7 @@ jobs:
git remote add pr-head "https://github.com/${{ github.event.pull_request.head.repo.full_name }}.git"
git fetch --no-tags --prune --depth=1 pr-head \
"+${{ github.event.pull_request.head.sha }}:refs/remotes/pr-head/current"

docker run --rm \
-v "$PWD:/repo" \
-w /repo \
Expand All @@ -54,4 +54,4 @@ jobs:
- name: Export requirements
run: uv export --frozen --no-dev --format requirements-txt -o /tmp/req.txt
- name: Run pip-audit
run: uvx pip-audit -r /tmp/req.txt --progress-spinner off
run: uvx pip-audit -r /tmp/req.txt --progress-spinner off
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -53,3 +53,4 @@ htmlcov/

# Project specific
.ace-tool/
.tmp_live/
34 changes: 26 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ docker compose up -d

### Vercel

[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https://github.com/chenyme/grok2api&env=LOG_LEVEL,LOG_FILE_ENABLED,DATA_DIR,LOG_DIR,ACCOUNT_STORAGE,ACCOUNT_REDIS_URL,ACCOUNT_MYSQL_URL,ACCOUNT_POSTGRESQL_URL)
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https://github.com/chenyme/grok2api&env=LOG_LEVEL,LOG_FILE_ENABLED,DATA_DIR,LOG_DIR,ACCOUNT_STORAGE,ACCOUNT_REDIS_URL,ACCOUNT_MYSQL_URL,ACCOUNT_POSTGRESQL_URL,CONFIG_STORAGE,CONFIG_LOCAL_PATH)

### Render

Expand Down Expand Up @@ -187,7 +187,8 @@ docker compose up -d
| `ACCOUNT_SQL_MAX_OVERFLOW` | SQL 连接池最大溢出连接数 | `10` |
| `ACCOUNT_SQL_POOL_TIMEOUT` | 等待连接池空闲连接的超时时间(秒) | `30` |
| `ACCOUNT_SQL_POOL_RECYCLE` | 连接最大复用时间(秒),超时后自动重连 | `1800` |
| `CONFIG_LOCAL_PATH` | `local` 模式运行时配置文件路径 | `${DATA_DIR}/config.toml` |
| `CONFIG_STORAGE` | 运行时配置存储后端;默认本地 TOML,不跟随 `ACCOUNT_STORAGE` | `local` |
| `CONFIG_LOCAL_PATH` | 本地运行时配置文件路径 | `${DATA_DIR}/config.toml` |

运行时配置也支持 `GROK_` 前缀环境变量覆盖,例如 `GROK_APP_API_KEY` 会覆盖 `app.api_key`,`GROK_FEATURES_STREAM` 会覆盖 `features.stream`。

Expand Down Expand Up @@ -362,15 +363,31 @@ curl http://localhost:8000/v1/chat/completions \
"model": "grok-imagine-video",
"stream": true,
"messages": [
{"role":"user","content":"霓虹雨夜街头,电影感慢镜头追拍"}
{"role":"user","content":"霓虹雨夜街头,电影感慢镜头追拍"},
{"role":"user","content":"镜头穿过霓虹招牌,人物回头看向远处车灯"}
],
"video_config": {
"metadata": {
"video_config": {
"seconds": 16,
"size": "1792x1024",
"resolution_name": "720p",
"preset": "normal"
}
}
}'
```

`image_config` / `video_config` 也可以放在请求顶层;顶层字段优先于 `metadata` 内的同名配置。

```json
{
"video_config": {
"seconds": 10,
"size": "1792x1024",
"resolution_name": "720p",
"preset": "normal"
}
}'
}
}
```

<details>
Expand All @@ -390,10 +407,11 @@ curl http://localhost:8000/v1/chat/completions \
| \|_ `size` | `1280x720`, `720x1280`, `1792x1024`, `1024x1792`, `1024x1024` |
| \|_ `response_format` | `url`, `b64_json` |
| `video_config` | 视频模型参数 |
| \|_ `seconds` | `6`, `10`, `12`, `16`, `20` |
| \|_ `seconds` | `6`, `10`, `12`, `16`, `20`, `22`, `26`, `30`, `32`, `36`, `40`;长视频需要按分段数量提供同数量 user 提示词 |
| \|_ `size` | `720x1280`, `1280x720`, `1024x1024`, `1024x1792`, `1792x1024` |
| \|_ `resolution_name` | `480p`, `720p` |
| \|_ `preset` | `fun`, `normal`, `spicy`, `custom` |
| `metadata.image_config` / `metadata.video_config` | `image_config` / `video_config` 的兼容位置;顶层配置优先 |

<br>
</details>
Expand Down Expand Up @@ -589,7 +607,7 @@ curl -L http://localhost:8000/v1/videos/<video_id>/content \
| :-- | :-- |
| `model` | 视频模型,目前为 `grok-imagine-video` |
| `prompt` | 视频生成提示词 |
| `seconds` | 视频长度:`6`, `10`, `12`, `16`, `20` |
| `seconds` | 视频长度:`6`, `10`;更长视频请使用 `/v1/chat/completions` |
| `size` | 支持 `720x1280`, `1280x720`, `1024x1024`, `1024x1792`, `1792x1024` |
| `resolution_name` | `480p` 或 `720p` |
| `preset` | `fun`, `normal`, `spicy`, `custom` |
Expand Down
55 changes: 42 additions & 13 deletions app/control/account/backends/local.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ def _init_sync(self) -> None:

CREATE TABLE IF NOT EXISTS {_TBL} (
token TEXT NOT NULL PRIMARY KEY,
account_id TEXT,
pool TEXT NOT NULL DEFAULT 'basic',
status TEXT NOT NULL DEFAULT 'active',
created_at INTEGER NOT NULL,
Expand Down Expand Up @@ -85,7 +86,13 @@ def _init_sync(self) -> None:
CREATE INDEX IF NOT EXISTS idx_acc_deleted
ON {_TBL} (deleted_at) WHERE deleted_at IS NOT NULL;
""")
# Migration: add account_id column if missing.
self._ensure_column_sync(conn, "account_id", "TEXT")
self._ensure_column_sync(conn, "quota_grok_4_3", "TEXT NOT NULL DEFAULT '{}'")
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_acc_account_id "
f"ON {_TBL} (account_id) WHERE account_id IS NOT NULL"
)
conn.commit()

@staticmethod
Expand All @@ -112,6 +119,7 @@ def _get_revision_sync(self, conn: sqlite3.Connection) -> int:
@staticmethod
def _row_to_record(row: sqlite3.Row) -> AccountRecord:
d = dict(row)
d["account_id"] = d.get("account_id") or None
d["tags"] = json.loads(d.get("tags") or "[]")
heavy_raw = d.pop("quota_heavy", "{}") or "{}"
grok_4_3_raw = d.pop("quota_grok_4_3", "{}") or "{}"
Expand All @@ -132,6 +140,7 @@ def _record_to_row(record: AccountRecord, revision: int) -> dict[str, Any]:
qs = record.quota_set()
return {
"token": record.token,
"account_id": record.account_id,
"pool": record.pool,
"status": record.status.value,
"created_at": record.created_at,
Expand Down Expand Up @@ -171,19 +180,36 @@ def _upsert_sync(
continue
pool = item.pool if item.pool in ("basic", "super", "heavy") else "basic"
qs = default_quota_set(pool)
account_id = item.account_id or None

# Dedup by account_id: soft-delete old token records with same account_id.
if account_id:
dups = conn.execute(
f"SELECT token FROM {_TBL} "
f"WHERE account_id = ? AND token != ? AND deleted_at IS NULL",
(account_id, token),
).fetchall()
for dup in dups:
conn.execute(
f"UPDATE {_TBL} SET deleted_at = ?, updated_at = ?, revision = ? "
f"WHERE token = ?",
(ts, ts, revision, dup[0]),
)

conn.execute(
f"""
INSERT INTO {_TBL} (
token, pool, status, created_at, updated_at,
token, account_id, pool, status, created_at, updated_at,
tags, quota_auto, quota_fast, quota_expert, quota_heavy, quota_grok_4_3,
usage_use_count, usage_fail_count, usage_sync_count,
ext, revision
) VALUES (
:token, :pool, 'active', :ts, :ts,
:token, :account_id, :pool, 'active', :ts, :ts,
:tags, :qa, :qf, :qe, :qh, :qg,
0, 0, 0, :ext, :rev
)
ON CONFLICT(token) DO UPDATE SET
account_id = COALESCE(excluded.account_id, account_id),
pool = excluded.pool,
status = 'active',
deleted_at = NULL,
Expand All @@ -193,17 +219,18 @@ def _upsert_sync(
revision = excluded.revision
""",
{
"token": token,
"pool": pool,
"ts": ts,
"tags": json.dumps(item.tags),
"qa": json.dumps(qs.auto.to_dict()),
"qf": json.dumps(qs.fast.to_dict()),
"qe": json.dumps(qs.expert.to_dict()),
"qh": json.dumps(qs.heavy.to_dict()) if qs.heavy else "{}",
"qg": json.dumps(qs.grok_4_3.to_dict()) if qs.grok_4_3 else "{}",
"ext": json.dumps(item.ext),
"rev": revision,
"token": token,
"account_id": account_id,
"pool": pool,
"ts": ts,
"tags": json.dumps(item.tags),
"qa": json.dumps(qs.auto.to_dict()),
"qf": json.dumps(qs.fast.to_dict()),
"qe": json.dumps(qs.expert.to_dict()),
"qh": json.dumps(qs.heavy.to_dict()) if qs.heavy else "{}",
"qg": json.dumps(qs.grok_4_3.to_dict()) if qs.grok_4_3 else "{}",
"ext": json.dumps(item.ext),
"rev": revision,
},
)
count += conn.execute("SELECT changes()").fetchone()[0]
Expand All @@ -229,6 +256,8 @@ def _patch_sync(

sets: dict[str, Any] = {"updated_at": ts, "revision": revision}

if patch.account_id is not None:
sets["account_id"] = patch.account_id
if patch.pool is not None:
sets["pool"] = patch.pool
if patch.status is not None:
Expand Down
51 changes: 44 additions & 7 deletions app/control/account/backends/redis.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ def __init__(self, redis: "Redis") -> None:
def _to_hash(record: AccountRecord, revision: int) -> dict[str, str]:
qs = record.quota_set()
return {
"account_id": record.account_id or "",
"pool": record.pool,
"status": record.status.value,
"created_at": str(record.created_at),
Expand All @@ -64,6 +65,7 @@ def _to_hash(record: AccountRecord, revision: int) -> dict[str, str]:
"quota_fast": json.dumps(qs.fast.to_dict()),
"quota_expert": json.dumps(qs.expert.to_dict()),
"quota_heavy": json.dumps(qs.heavy.to_dict()) if qs.heavy else "{}",
"quota_grok_4_3": json.dumps(qs.grok_4_3.to_dict()) if qs.grok_4_3 else "{}",
"usage_use_count": str(record.usage_use_count),
"usage_fail_count": str(record.usage_fail_count),
"usage_sync_count": str(record.usage_sync_count),
Expand All @@ -88,8 +90,10 @@ def _i(k: str) -> int | None:
v = _s(k)
return int(v) if v else None

aid = _s("account_id")
return AccountRecord.model_validate({
"token": token,
"account_id": aid if aid else None,
"pool": _s("pool") or "basic",
"status": _s("status") or "active",
"created_at": _i("created_at") or now_ms(),
Expand All @@ -102,6 +106,9 @@ def _i(k: str) -> int | None:
**({
"heavy": json.loads(_s("quota_heavy"))
} if _s("quota_heavy") and _s("quota_heavy") != "{}" else {}),
**({
"grok_4_3": json.loads(_s("quota_grok_4_3"))
} if _s("quota_grok_4_3") and _s("quota_grok_4_3") != "{}" else {}),
},
"usage_use_count": int(_s("usage_use_count") or 0),
"usage_fail_count": int(_s("usage_fail_count") or 0),
Expand Down Expand Up @@ -207,14 +214,40 @@ async def upsert_accounts(
pool = item.pool if item.pool in ("basic", "super", "heavy") else "basic"
qs = default_quota_set(pool)
ts = now_ms()
account_id = item.account_id or None

# Dedup by account_id: scan for existing records with same account_id.
if account_id:
async for key in self._r.scan_iter("accounts:record:*"):
h = await self._r.hgetall(key)
if not h:
continue
existing_aid = (h.get(b"account_id") or h.get("account_id") or b"")
if isinstance(existing_aid, bytes):
existing_aid = existing_aid.decode()
if existing_aid == account_id:
dup_token = (key.decode() if isinstance(key, bytes) else key).split(":", 2)[-1]
if dup_token != token:
await self._r.hset(key, mapping={
"deleted_at": str(ts),
"updated_at": str(ts),
"revision": str(rev),
})
# Remove from pool set and add to deleted pool tracking.
dup_pool = (h.get(b"pool") or h.get("pool") or b"basic")
if isinstance(dup_pool, bytes):
dup_pool = dup_pool.decode()
await self._r.srem(_pool_key(dup_pool), dup_token)

record = AccountRecord(
token = token,
pool = pool,
tags = item.tags,
ext = item.ext,
quota = qs.to_dict(),
created_at = ts,
updated_at = ts,
token = token,
account_id = account_id,
pool = pool,
tags = item.tags,
ext = item.ext,
quota = qs.to_dict(),
created_at = ts,
updated_at = ts,
)
key = _record_key(token)
await self._r.hset(key, mapping=self._to_hash(record, rev))
Expand Down Expand Up @@ -258,6 +291,8 @@ async def patch_accounts(
updates["last_sync_at"] = str(patch.last_sync_at)
if patch.last_clear_at is not None:
updates["last_clear_at"] = str(patch.last_clear_at)
if patch.account_id is not None:
updates["account_id"] = patch.account_id
if patch.pool is not None:
updates["pool"] = patch.pool
if patch.quota_auto is not None:
Expand All @@ -268,6 +303,8 @@ async def patch_accounts(
updates["quota_expert"] = json.dumps(patch.quota_expert)
if patch.quota_heavy is not None:
updates["quota_heavy"] = json.dumps(patch.quota_heavy)
if patch.quota_grok_4_3 is not None:
updates["quota_grok_4_3"] = json.dumps(patch.quota_grok_4_3)

# Usage counters.
if patch.usage_use_delta is not None:
Expand Down
Loading