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
24 changes: 23 additions & 1 deletion .github/workflows/docker-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,37 @@ env:
IMAGE_NAME: chatgpt2api

jobs:
test:
runs-on: ubuntu-latest
permissions:
contents: read

steps:
- name: Checkout
uses: actions/checkout@v5

- name: Set up uv and Python
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
with:
python-version: "3.13"
enable-cache: true

- name: Install dependencies
run: uv sync --frozen --group dev

- name: Run offline tests
run: uv run --frozen pytest -m "not live" -q

docker:
needs: test
runs-on: ubuntu-latest
permissions:
contents: read
packages: write

steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v5

- name: Set up QEMU
uses: docker/setup-qemu-action@v3
Expand Down
100 changes: 100 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
name: Test

on:
push:
branches:
- main
pull_request:

permissions:
contents: read

jobs:
backend:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v5

- name: Set up uv and Python
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
with:
python-version: "3.13"
enable-cache: true

- name: Install dependencies
run: uv sync --frozen --group dev

- name: Run offline tests
run: uv run --frozen pytest -m "not live" -q

frontend:
runs-on: ubuntu-latest
defaults:
run:
working-directory: web
steps:
- name: Checkout
uses: actions/checkout@v5

- name: Set up Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.14

- name: Install dependencies
run: bun install --frozen-lockfile

- name: Build frontend
run: bun run build

docker:
needs: [backend, frontend]
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v5

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3

- name: Build application image
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile
target: app
platforms: linux/amd64
load: true
push: false
tags: chatgpt2api:test
cache-from: type=gha
cache-to: type=gha,mode=max

- name: Smoke test application image
shell: bash
run: |
set -euo pipefail
container="chatgpt2api-smoke-${GITHUB_RUN_ID}"
cleanup() {
status=$?
if [ "$status" -ne 0 ]; then
docker logs "$container" || true
fi
docker rm -f "$container" >/dev/null 2>&1 || true
return "$status"
}
trap cleanup EXIT

docker run -d --name "$container" \
-e CHATGPT2API_AUTH_KEY=ci-smoke-test \
chatgpt2api:test

for _ in $(seq 1 30); do
if docker exec "$container" /app/.venv/bin/python -c \
'import urllib.request; urllib.request.urlopen("http://127.0.0.1/openapi.json", timeout=2).read(1)'; then
exit 0
fi
sleep 1
done
exit 1
8 changes: 4 additions & 4 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,20 @@
ARG TARGETPLATFORM
ARG TARGETARCH

FROM --platform=$BUILDPLATFORM node:22-alpine AS web-build
FROM --platform=$BUILDPLATFORM oven/bun:1.3.14-alpine AS web-build

WORKDIR /app/web

COPY web/package.json web/bun.lock ./
RUN npm install
RUN bun install --frozen-lockfile

COPY VERSION /app/VERSION
COPY CHANGELOG.md /app/CHANGELOG.md
COPY web ./
RUN NEXT_PUBLIC_APP_VERSION="$(cat /app/VERSION)" npm run build
RUN NEXT_PUBLIC_APP_VERSION="$(cat /app/VERSION)" bun run build


FROM --platform=$TARGETPLATFORM python:3.13-slim AS app

Check warning on line 18 in Dockerfile

View workflow job for this annotation

GitHub Actions / docker

Setting platform to predefined $TARGETPLATFORM in FROM is redundant as this is the default behavior

RedundantTargetPlatform: Setting platform to predefined $TARGETPLATFORM in FROM is redundant as this is the default behavior More info: https://docs.docker.com/go/dockerfile/rule/redundant-target-platform/

ARG TARGETPLATFORM
ARG TARGETARCH
Expand Down Expand Up @@ -53,4 +53,4 @@

EXPOSE 80

CMD ["uv", "run", "uvicorn", "main:app", "--host", "0.0.0.0", "--port", "80", "--access-log"]
CMD ["/app/.venv/bin/uvicorn", "main:app", "--host", "0.0.0.0", "--port", "80", "--access-log"]
69 changes: 17 additions & 52 deletions api/image_inputs.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,16 @@
import base64
import binascii
import json
import mimetypes
import re
from pathlib import PurePosixPath
from typing import Any, TypeGuard
from urllib.parse import unquote, unquote_to_bytes, urlparse
from urllib.parse import unquote, unquote_to_bytes

from curl_cffi import requests
from fastapi import HTTPException, Request
from fastapi.concurrency import run_in_threadpool
from starlette.datastructures import UploadFile

from services.proxy_service import proxy_settings
from utils.remote_image import download_public_image

ImageInput = tuple[bytes, str, str]
ImageSource = str | UploadFile | ImageInput
Expand Down Expand Up @@ -215,7 +213,7 @@ def _safe_filename(name: str, mime_type: str, fallback: str) -> str:
return cleaned


def _decode_data_url(url: str) -> ImageInput:
def _decode_data_url(url: str, fallback_name: str = "image_url") -> ImageInput:
"""解码 data URL:把内联图片转成标准图片输入元组。"""
header, separator, payload = url.partition(",")
if not separator:
Expand All @@ -231,66 +229,33 @@ def _decode_data_url(url: str) -> ImageInput:
raise HTTPException(status_code=400, detail={"error": "image URL is empty"})
if len(data) > MAX_IMAGE_REFERENCE_BYTES:
raise HTTPException(status_code=400, detail={"error": "image URL exceeds 50MB limit"})
return data, f"image_url.{_extension_from_mime(mime_type)}", mime_type
return data, _safe_filename(fallback_name, mime_type, "image_url"), mime_type


def _response_mime_type(response: requests.Response, parsed_path: str) -> str:
"""识别下载图片类型:优先响应头,必要时按 URL 后缀推断。"""
header_type = str(response.headers.get("content-type") or "").split(";", 1)[0].strip().lower()
guessed_type = mimetypes.guess_type(parsed_path)[0] or ""
if header_type.startswith("image/"):
return header_type
if header_type and header_type not in {"application/octet-stream", "binary/octet-stream"}:
raise HTTPException(status_code=400, detail={"error": "image_url must point to an image"})
if guessed_type.startswith("image/"):
return guessed_type
if not header_type or header_type in {"application/octet-stream", "binary/octet-stream"}:
return "image/png"
raise HTTPException(status_code=400, detail={"error": "image_url must point to an image"})


def _filename_from_url(parsed_path: str, mime_type: str) -> str:
def _filename_from_url(parsed_path: str, mime_type: str, fallback_name: str = "image_url") -> str:
"""生成 URL 图片文件名:从链接路径提取名称并做安全化。"""
raw_name = PurePosixPath(unquote(parsed_path)).name
return _safe_filename(raw_name, mime_type, "image_url")
return _safe_filename(raw_name, mime_type, fallback_name)


def _download_image_url(url: str) -> ImageInput:
def _download_image_url(url: str, fallback_name: str = "image_url") -> ImageInput:
"""下载远程图片:把 http/https 图片链接转成标准图片输入元组。"""
source = _clean(url)
if source.startswith("data:"):
return _decode_data_url(source)
parsed = urlparse(source)
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
raise HTTPException(status_code=400, detail={"error": "image_url must be an http or https URL"})
try:
response = requests.get(
source,
headers={"Accept": "image/*,*/*;q=0.8", "User-Agent": "chatgpt2api image fetcher"},
timeout=60,
allow_redirects=True,
**proxy_settings.build_session_kwargs(),
)
except Exception as exc:
raise HTTPException(status_code=400, detail={"error": f"image_url fetch failed: {exc}"}) from exc
if not 200 <= response.status_code < 300:
raise HTTPException(status_code=400, detail={"error": f"image_url fetch failed: HTTP {response.status_code}"})
content_length = _clean(response.headers.get("content-length"))
if content_length and content_length.isdigit() and int(content_length) > MAX_IMAGE_REFERENCE_BYTES:
raise HTTPException(status_code=400, detail={"error": "image_url exceeds 50MB limit"})
data = response.content
if not data:
raise HTTPException(status_code=400, detail={"error": "image_url returned empty content"})
if len(data) > MAX_IMAGE_REFERENCE_BYTES:
raise HTTPException(status_code=400, detail={"error": "image_url exceeds 50MB limit"})
mime_type = _response_mime_type(response, parsed.path)
return data, _filename_from_url(parsed.path, mime_type), mime_type
return _decode_data_url(source, fallback_name)
data, parsed_path, mime_type = download_public_image(
source,
max_bytes=MAX_IMAGE_REFERENCE_BYTES,
timeout_seconds=60,
user_agent="chatgpt2api image fetcher",
)
return data, _filename_from_url(parsed_path, mime_type, fallback_name), mime_type


async def read_image_sources(sources: list[ImageSource]) -> list[ImageInput]:
"""读取图片来源:上传文件直接读取,URL 下载后统一返回图片元组。"""
images: list[ImageInput] = []
for source in sources:
for index, source in enumerate(sources, start=1):
if isinstance(source, tuple):
images.append(source)
continue
Expand All @@ -303,7 +268,7 @@ async def read_image_sources(sources: list[ImageSource]) -> list[ImageInput]:
raise HTTPException(status_code=400, detail={"error": "image file is empty"})
images.append((image_data, source.filename or "image.png", source.content_type or "image/png"))
continue
images.append(await run_in_threadpool(_download_image_url, source))
images.append(await run_in_threadpool(_download_image_url, source, f"image_{index}"))
if not images:
raise HTTPException(status_code=400, detail={"error": "image file or image_url is required"})
return images
5 changes: 1 addition & 4 deletions config.json
Original file line number Diff line number Diff line change
Expand Up @@ -88,10 +88,7 @@
"ttl_seconds": 60,
"max_entries": 256,
"dedupe_inflight": true,
"stream_cache": true,
"normalize_messages": true,
"drop_adjacent_duplicates": true,
"drop_assistant_history": false
"stream_cache": true
},
"image_settle_enabled": false,
"image_check_before_hit_enabled": false,
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ dependencies = [
[dependency-groups]
dev = [
"httpx>=0.28.1",
"pytest>=8.3.0",
]

[[tool.uv.index]]
Expand Down
5 changes: 5 additions & 0 deletions pytest.ini
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
[pytest]
testpaths = test
addopts = --strict-markers
markers =
live: requires a running ChatGPT2API service and real upstream credentials
25 changes: 14 additions & 11 deletions services/account_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -356,7 +356,11 @@ def _request_access_token_refresh(self, refresh_token: str, account: dict | None
from curl_cffi import requests
from services.proxy_service import proxy_settings

session = requests.Session(**proxy_settings.build_session_kwargs(account=account, impersonate="chrome110", verify=True))
session = requests.Session(**proxy_settings.build_session_kwargs(
account=account,
impersonate="chrome110",
verify=True,
))
try:
response = session.post(
self._OAUTH_TOKEN_URL,
Expand Down Expand Up @@ -585,6 +589,7 @@ def _password_re_login_thread(self, access_token: str, email: str, password: str
def _login_with_password(self, email: str, password: str) -> dict:
"""通过邮箱+密码登录,返回 {access_token, refresh_token, id_token, ...}"""
from curl_cffi import requests
from services.proxy_service import proxy_settings

# 常量
auth_base = "https://auth.openai.com"
Expand All @@ -595,10 +600,11 @@ def _login_with_password(self, email: str, password: str) -> dict:
user_agent = self._OAUTH_USER_AGENT

# 创建 session
session_kwargs = {"impersonate": "chrome110", "verify": False}
proxy = config.get_proxy_settings()
if proxy:
session_kwargs["proxy"] = proxy
session_kwargs = proxy_settings.build_session_kwargs(
proxy=config.get_proxy_settings(),
impersonate="chrome110",
verify=False,
)
session = requests.Session(**session_kwargs)

try:
Expand Down Expand Up @@ -774,7 +780,6 @@ def _login_with_password(self, email: str, password: str) -> dict:
"code": auth_code,
"redirect_uri": platform_oauth_redirect_uri,
},
verify=False,
timeout=60,
)

Expand Down Expand Up @@ -1014,21 +1019,19 @@ def get_text_access_token(
token
for account in self._accounts.values()
if account.get("status") not in {"禁用", "异常"}
and (token := account.get("access_token") or "")
and (
route is None
or self._normalize_account_type(account.get("type")) in route.account_types
or token in route.access_tokens
)
and (token := account.get("access_token") or "")
and token not in excluded
]
if not candidates:
if route is None or route.allow_anonymous:
return ""
from services.model_service import ModelUnavailableError

raise ModelUnavailableError(
f"model {requested_model!r} is not available to any active account"
)
raise ModelUnavailableError(requested_model)
access_token = candidates[self._index % len(candidates)]
self._index += 1
return self.refresh_access_token(access_token, event="get_text_access_token") or access_token
Expand Down
Loading
Loading