Skip to content
Merged
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
48 changes: 46 additions & 2 deletions .github/workflows/docker-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,16 @@ name: Publish Docker Image

on:
push:
branches:
- main
tags:
- "v*"
workflow_dispatch:
inputs:
platforms:
description: "Comma-separated Docker platforms to build"
required: false
default: "linux/amd64"

env:
IMAGE_NAME: webchat2api
Expand All @@ -22,7 +29,44 @@ jobs:
- name: Checkout
uses: actions/checkout@v4

- name: Prepare Docker build settings
id: build_settings
shell: bash
env:
REQUESTED_PLATFORMS: ${{ github.event.inputs.platforms || '' }}
run: |
if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" && -n "${REQUESTED_PLATFORMS}" ]]; then
platforms="${REQUESTED_PLATFORMS}"
elif [[ "${GITHUB_REF_TYPE}" == "tag" ]]; then
platforms="linux/amd64,linux/arm64"
else
platforms="linux/amd64"
fi

platforms="${platforms//[[:space:]]/}"
if [[ -z "${platforms}" ]]; then
platforms="linux/amd64"
fi

IFS=',' read -ra requested_platforms <<< "${platforms}"
for platform in "${requested_platforms[@]}"; do
if [[ "${platform}" != "linux/amd64" && "${platform}" != "linux/arm64" ]]; then
echo "Unsupported Docker platform: ${platform}. Supported platforms: linux/amd64,linux/arm64" >&2
exit 1
fi
done

if [[ "${platforms}" == *,* ]]; then
cache_mode="max"
else
cache_mode="min"
fi

echo "platforms=${platforms}" >> "$GITHUB_OUTPUT"
echo "cache_mode=${cache_mode}" >> "$GITHUB_OUTPUT"

- name: Set up QEMU
if: ${{ contains(steps.build_settings.outputs.platforms, 'arm') }}
uses: docker/setup-qemu-action@v3
with:
platforms: arm64
Expand Down Expand Up @@ -64,9 +108,9 @@ jobs:
context: .
file: ./Dockerfile
target: app
platforms: linux/amd64,linux/arm64
platforms: ${{ steps.build_settings.outputs.platforms }}
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
cache-to: type=gha,mode=${{ steps.build_settings.outputs.cache_mode }}
9 changes: 7 additions & 2 deletions .github/workflows/pr-target-branch.yml
Original file line number Diff line number Diff line change
Expand Up @@ -113,9 +113,14 @@ jobs:
name: wrongTargetBranchLabel,
});
} catch (error) {
if (error.status !== 404) {
throw error;
if (error.status === 404) {
return;
}
if (error.status === 403) {
core.info(`Could not remove ${wrongTargetBranchLabel} label: ${error.message}`);
return;
}
throw error;
}
};

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ npm run dev

Grok Console 与 grok.com app-chat 是不同上游路径。本项目没有接入官方 xAI API,也不声称提供官方兼容能力。Console 路径可使用 `network_profiles.grok_console.cf_clearance` 附加手动 Cookie;app-chat 路径可使用 `network_profiles.grok_app_chat` 覆盖 UA、impersonate、`cf_clearance`、`cf_cookies`、`sec-ch-ua`、`x-statsig-id` 等字段。

如配置 `flaresolverr_url`,直接 app-chat 请求遇到 Cloudflare 或 403 时会尝试通过 FlareSolverr 刷新 clearance 并重试。Browser Bridge 是独立浏览器路径,后端会优先使用 `browser_bridge_url`,未配置时会探测 `http://127.0.0.1:3080/health`。Browser Bridge 的接口是 `POST /api/chat {sso,payload}` 和 `GET /health`,请求会经真实 Chromium 页面发往 grok.com。
如配置 `flaresolverr_url`,直接 app-chat 请求遇到 Cloudflare 或 403 时会尝试通过 FlareSolverr 刷新 clearance 并重试。Browser Bridge 是独立浏览器路径;显式配置 `browser_bridge_url` 时,后端会优先使用该 Bridge。未配置时,app-chat 默认先走直接请求;直接请求遇到 `403`、`408`、`502`、`503`、`504` 时,才会尝试探测并回退到 `http://127.0.0.1:3080/health` 对应的 Browser Bridge。Browser Bridge 的接口是 `POST /api/chat {sso,payload}` 和 `GET /health`,请求会经真实 Chromium 页面发往 grok.com。

> [!WARNING]
> Cloudflare、WAF、账号风控和上游配额都可能变化。手动 clearance、FlareSolverr 和 Browser Bridge 都是尽力而为,不能保证长期可用。
Expand Down
26 changes: 20 additions & 6 deletions services/protocol/openai_v1_chat_complete.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,12 +115,26 @@ def stream_grok_chat_completion(body: dict[str, Any], spec, messages: list[dict[
return
completion_id = f"chatcmpl-{uuid.uuid4().hex}"
created = int(time.time())
response = grok.console_chat_completion(body, spec, messages)
if response.reasoning_content:
yield completion_chunk(model, {"role": "assistant", "reasoning_content": response.reasoning_content}, None, completion_id, created)
yield completion_chunk(model, {"content": response.content}, None, completion_id, created)
else:
yield completion_chunk(model, {"role": "assistant", "content": response.content}, None, completion_id, created)
sent_role = False
for event in grok.console_chat_completion_events(body, spec, messages):
delta = grok.extract_console_stream_delta(event)
if not delta.content and not delta.reasoning_content:
continue
if not sent_role:
sent_role = True
first_delta: dict[str, Any] = {"role": "assistant"}
if delta.reasoning_content:
first_delta["reasoning_content"] = delta.reasoning_content
else:
first_delta["content"] = delta.content
yield completion_chunk(model, first_delta, None, completion_id, created)
continue
if delta.reasoning_content:
yield completion_chunk(model, {"reasoning_content": delta.reasoning_content}, None, completion_id, created)
else:
yield completion_chunk(model, {"content": delta.content}, None, completion_id, created)
if not sent_role:
yield completion_chunk(model, {"role": "assistant", "content": ""}, None, completion_id, created)
yield completion_chunk(model, {}, "stop", completion_id, created)


Expand Down
30 changes: 30 additions & 0 deletions services/protocol/openai_v1_response.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@

from fastapi import HTTPException

from services.models import GROK_PROVIDER, is_grok_app_chat_model, resolve_model
from services.providers import grok
from services.protocol.conversation import (
ConversationRequest,
ImageOutput,
Expand Down Expand Up @@ -150,6 +152,29 @@ def stream_text_response(backend, body: dict[str, Any]) -> Iterator[dict[str, An
yield response_completed(response_id, model, created, [item])


def stream_grok_console_response(body: dict[str, Any]) -> Iterator[dict[str, Any]]:
model = str(body.get("model") or "auto").strip() or "auto"
spec = resolve_model(model)
if is_grok_app_chat_model(spec):
raise HTTPException(status_code=501, detail={"error": "Grok app-chat is not supported on /v1/responses"})
messages = messages_from_input(body.get("input"), body.get("instructions"))
if not messages:
raise HTTPException(status_code=400, detail={"error": "input text is required"})
response_id = f"resp_{uuid.uuid4().hex}"
item_id = f"msg_{uuid.uuid4().hex}"
created = int(time.time())
yield response_created(response_id, model, created)
yield {"type": "response.output_item.added", "output_index": 0, "item": text_output_item("", item_id, "in_progress")}
completion = grok.console_chat_completion(body, spec, messages)
text = completion.content
if text:
yield {"type": "response.output_text.delta", "item_id": item_id, "output_index": 0, "content_index": 0, "delta": text}
yield {"type": "response.output_text.done", "item_id": item_id, "output_index": 0, "content_index": 0, "text": text}
item = text_output_item(text, item_id, "completed")
yield {"type": "response.output_item.done", "output_index": 0, "item": item}
yield response_completed(response_id, model, created, [item])


def stream_image_response(image_outputs: Iterable[ImageOutput], prompt: str, model: str) -> Iterator[dict[str, Any]]:
response_id = f"resp_{uuid.uuid4().hex}"
created = int(time.time())
Expand Down Expand Up @@ -186,6 +211,11 @@ def collect_response(events: Iterable[dict[str, Any]]) -> dict[str, Any]:

def response_events(body: dict[str, Any]) -> Iterator[dict[str, Any]]:
if is_text_response_request(body):
model = str(body.get("model") or "auto").strip() or "auto"
spec = resolve_model(model)
if spec.provider == GROK_PROVIDER:
yield from stream_grok_console_response(body)
return
yield from stream_text_response(text_backend(), body)
return

Expand Down
Loading
Loading