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
11 changes: 7 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,21 +24,24 @@
> Check out [DEEIX-AI / DEEIX-Chat](https://github.com/DEEIX-AI/DEEIX-Chat), a lightweight, integrated AI platform for model routing, chat, files, tools, billing, identity, and operations.

> [!NOTE]
> This project is intended for technical research and self-hosted use. Make sure your accounts, network setup, and upstream usage comply with the applicable terms and local requirements.
> This project is for technical research and learning purposes only. Please comply with Grok's official terms of use and local laws when using it; otherwise, you will be solely responsible for all consequences!

## ❤️ Sponsors
## Sponsors
> [Want to sponsor this project?](mailto:chenyme03@gmail.com)

<table>
<tr>
<td width="200" align="center" valign="middle"><a href="https://github.com/DEEIX-AI/DEEIX-Chat"><img src="frontend/public/sponner/deeix-chat_deeix-ai.png" alt="DEEIX AI / DEEIX Chat" width="160"></a></td>
<td valign="middle">DEEIX-Chat is an open-source, deployable AI platform for individuals, teams, and organizations that need stable, long-term access to multiple models. It brings multimodal chat, model routing, files and RAG, MCP tools, usage billing, authentication, audit logs, and operational controls into one product.</td>
<td valign="middle">DEEIX-Chat is an open-source, self-hostable AI Chat platform for individuals, teams, and enterprises that need stable, long-term, unified access to multiple models. It brings models, conversations, files, tool calling, and administration together in one deployable and extensible system. Click <a href="https://github.com/DEEIX-AI/DEEIX-Chat">here</a> to start deploying.</td>
</tr>
<tr>
<td width="200" align="center" valign="middle"><a href="https://www.right.codes/register"><img src="frontend/public/sponner/rightcode.jpg" alt="RightCode" width="160"></a></td>
<td valign="middle">Right Code provides stable access services for Claude Code, Codex, Gemini, and other models. It supports invoicing and one-to-one assistance for businesses and teams. Part of the model capacity used to develop this project is provided by Right Code. Thanks to the Right Code team for supporting the project. <a href="https://www.right.codes/register">Register here</a> to get started.</td>
<td valign="middle">Right Code is an enterprise-grade AI Agent distribution platform that primarily provides stable access services for Claude Code, Codex, Gemini, and other models. It supports invoicing and dedicated one-to-one assistance for enterprises and teams. Thanks to Right Code for providing token support. Click <a href="https://www.right.codes/register">here</a> to register and get started.</td>
</tr>
</table>

<br>

Grok2API is a Go-based Grok API gateway with a built-in React admin console. It organizes Grok Build OAuth, Grok Web SSO, and Grok Console SSO credentials into independent account pools, exposes OpenAI- and Anthropic-style APIs, and provides one place to manage model routes, client keys, quotas, media, audits, and egress proxies.

## Highlights
Expand Down
11 changes: 8 additions & 3 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,11 @@
> 推荐个人新项目 [DEEIX-AI / DEEIX-Chat](https://github.com/DEEIX-AI/DEEIX-Chat):面向多模型路由、对话、文件、工具、计费与运维的一体化轻量 AI 平台。

> [!NOTE]
> 本项目用于技术研究与个人部署。使用前请确认账号、网络和上游服务符合相应条款及所在地要求。
> 本项目仅供技术研究与学习交流。使用时请务必遵循 Grok 官方的使用条款及当地法律法规,否则一切后果自负!

## ❤️ 赞助商
## 赞助商

> [希望赞助这个项目?](mailto:chenyme03@gmail.com)

<table>
<tr>
Expand All @@ -35,10 +37,12 @@
</tr>
<tr>
<td width="200" align="center" valign="middle"><a href="https://www.right.codes/register"><img src="frontend/public/sponner/rightcode.jpg" alt="RightCode" width="160"></a></td>
<td valign="middle">Right Code 是一个企业级 AI Agent 分发平台,主要提供稳定的 Claude Code、Codex、Gemini 等模型的中转服务。充值即可开票,企业、团队用户一对一对接。感谢 RightCode 提供的 Tokens 支持,点击 <a href="https://www.right.codes/register">此处</a> 注册并开始使用!</td>
<td valign="middle">Right Code 是一个企业级 AI Agent 分发平台,主要提供稳定的 Claude Code、Codex、Gemini 等模型的中转服务。充值即可开票,企业、团队用户一对一对接。感谢 Right Code 提供的 Tokens 支持,点击 <a href="https://www.right.codes/register">此处</a> 注册并开始使用!</td>
</tr>
</table>

<br>

Grok2API 是一个以 Go 为核心、内置 React 管理端的 Grok API 网关。它将 Grok Build OAuth、Grok Web SSO 与 Grok Console SSO 组织成相互独立的账号池,对外提供 OpenAI 与 Anthropic 风格接口,并统一管理模型路由、客户端密钥、额度、媒体、审计和出口代理。

## 功能概览
Expand Down Expand Up @@ -377,5 +381,6 @@ make swagger

## 进一步阅读

- [English README](./README.md)
- [后端说明](./backend/README.md)
- [前端说明](./frontend/README.md)
12 changes: 10 additions & 2 deletions backend/internal/application/account/provider_links.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@ package account

import (
"context"
"errors"
"fmt"
"strings"

accountdomain "github.com/chenyme/grok2api/backend/internal/domain/account"
"github.com/chenyme/grok2api/backend/internal/infra/provider"
)

type providerLinkRepository interface {
Expand All @@ -14,7 +16,7 @@ type providerLinkRepository interface {
}

// SyncAccountIdentity 尽力补充 Web/Console 的稳定上游身份,并据此建立高可信弱关联。
// 该操作不修改账号凭据、健康、额度或路由状态
// 只有明确的 401 会将当前 Provider 账号移出号池;其他同步失败不影响健康状态
func (s *Service) SyncAccountIdentity(ctx context.Context, id uint64) error {
_, err, _ := s.identitySyncs.Do(fmt.Sprintf("%d", id), func() (any, error) {
return nil, s.syncAccountIdentity(ctx, id)
Expand Down Expand Up @@ -48,6 +50,10 @@ func (s *Service) syncAccountIdentity(ctx context.Context, id uint64) error {
}
identity, err := adapter.SyncAccountIdentity(ctx, value)
if err != nil {
if errors.Is(err, provider.ErrUnauthorized) {
markErr := s.markSSOCredentialRejected(ctx, value, fmt.Sprintf("%s SSO credential rejected", value.Provider))
return errors.Join(err, markErr)
}
return err
}
if len(identity.Email) > 255 || len(identity.UserID) > 255 || len(identity.TeamID) > 255 {
Expand All @@ -69,8 +75,10 @@ func (s *Service) reconcileProviderLinksBestEffort(ctx context.Context, id uint6
}
}

func (s *Service) syncAccountIdentityBestEffort(ctx context.Context, id uint64) {
func (s *Service) syncAccountIdentityBestEffort(ctx context.Context, id uint64) error {
if err := s.SyncAccountIdentity(ctx, id); err != nil {
s.logger.Warn("account_identity_sync_failed", "account_id", id, "error", err)
return err
}
return nil
}
6 changes: 3 additions & 3 deletions backend/internal/application/account/provider_links_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ func TestSyncAccountIdentityLinksUniqueBuildWithoutSharingState(t *testing.T) {
}
}

func TestSyncAccountIdentityFailureDoesNotInvalidateAccount(t *testing.T) {
func TestSyncAccountIdentityUnauthorizedInvalidatesCurrentProviderAccount(t *testing.T) {
t.Parallel()
ctx := context.Background()
service, repo, adapter := newWebAccountSettingsTestService(t)
Expand All @@ -82,8 +82,8 @@ func TestSyncAccountIdentityFailureDoesNotInvalidateAccount(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if web.AuthStatus != accountdomain.AuthStatusActive || !web.Enabled || web.FailureCount != 0 {
t.Fatalf("identity failure changed account state: %#v", web)
if web.AuthStatus != accountdomain.AuthStatusReauthRequired || !web.Enabled || web.FailureCount != 0 {
t.Fatalf("identity unauthorized state = %#v", web)
}
}

Expand Down
37 changes: 36 additions & 1 deletion backend/internal/application/account/quota_refresh_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package account

import (
"context"
"errors"
"path/filepath"
"sync/atomic"
"testing"
Expand Down Expand Up @@ -132,6 +133,39 @@ func TestRefreshQuotaFetchesWebIdentityOnlyUntilDataExists(t *testing.T) {
}
}

func TestRefreshQuotaUnauthorizedMarksWebAccountInvalid(t *testing.T) {
ctx := context.Background()
database, err := relational.OpenSQLite(ctx, filepath.Join(t.TempDir(), "quota-unauthorized.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = database.Close() })
if err := database.InitializeSchema(ctx); err != nil {
t.Fatal(err)
}
accounts := relational.NewAccountRepository(database)
credential, _, err := accounts.UpsertByIdentity(ctx, accountdomain.Credential{
Provider: accountdomain.ProviderWeb, AuthType: accountdomain.AuthTypeSSO,
Name: "web-unauthorized", SourceKey: "web-unauthorized", EncryptedAccessToken: "encrypted",
Enabled: true, AuthStatus: accountdomain.AuthStatusActive,
})
if err != nil {
t.Fatal(err)
}
adapter := &quotaCountingAdapter{fullErr: provider.ErrUnauthorized}
service := NewService(accounts, nil, nil, nil, provider.NewRegistry(adapter), nil, nil)
if _, err := service.RefreshQuota(ctx, credential.ID); !errors.Is(err, provider.ErrUnauthorized) {
t.Fatalf("err = %v", err)
}
stored, err := accounts.Get(ctx, credential.ID)
if err != nil {
t.Fatal(err)
}
if stored.AuthStatus != accountdomain.AuthStatusReauthRequired || !stored.Enabled {
t.Fatalf("account state = %#v", stored)
}
}

type deniedQuotaRefreshLock struct{}

func (deniedQuotaRefreshLock) Acquire(context.Context, string, time.Duration) (func(), bool, error) {
Expand All @@ -142,6 +176,7 @@ type quotaCountingAdapter struct {
modeCalls atomic.Int64
fullCalls atomic.Int64
identityCalls atomic.Int64
fullErr error
}

func (a *quotaCountingAdapter) Provider() accountdomain.Provider { return accountdomain.ProviderWeb }
Expand All @@ -155,7 +190,7 @@ func (a *quotaCountingAdapter) Definition() provider.Definition {

func (a *quotaCountingAdapter) SyncQuota(context.Context, accountdomain.Credential) (provider.QuotaSnapshot, error) {
a.fullCalls.Add(1)
return provider.QuotaSnapshot{}, nil
return provider.QuotaSnapshot{}, a.fullErr
}

func (a *quotaCountingAdapter) SyncAccountIdentity(context.Context, accountdomain.Credential) (provider.AccountIdentity, error) {
Expand Down
26 changes: 22 additions & 4 deletions backend/internal/application/account/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ const (
credentialRefreshSafetyPoll time.Duration = time.Minute
credentialRefreshTimeout time.Duration = 30 * time.Second
credentialRefreshStateTTL time.Duration = 5 * time.Second
credentialStateWriteTimeout time.Duration = 5 * time.Second
credentialRefreshBatchSize = 100
managedTaskWorkerCeiling = 50
webQuotaRefreshQueueSize = 4096
Expand Down Expand Up @@ -1254,7 +1255,7 @@ func (s *Service) convertWebAccountToBuild(ctx context.Context, id uint64, strat
seed, err := converter.ConvertToBuild(ctx, value)
if err != nil {
if errors.Is(err, provider.ErrUnauthorized) {
_ = s.MarkReauthRequired(context.WithoutCancel(ctx), id, "Grok Web SSO credential rejected")
err = errors.Join(err, s.markSSOCredentialRejected(ctx, value, "Grok Web SSO credential rejected"))
}
return 0, false, false, err
}
Expand Down Expand Up @@ -1433,6 +1434,21 @@ func (s *Service) MarkReauthRequired(ctx context.Context, id uint64, reason stri
return nil
}

// markSSOCredentialRejected 在上游明确返回 401 后可靠持久化失效状态。
// 状态写入不继承客户端取消,避免已经确认失效的账号因请求断开继续留在号池。
func (s *Service) markSSOCredentialRejected(ctx context.Context, value accountdomain.Credential, reason string) error {
if value.AuthType != accountdomain.AuthTypeSSO {
return nil
}
writeCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), credentialStateWriteTimeout)
defer cancel()
if err := s.MarkReauthRequired(writeCtx, value.ID, reason); err != nil {
s.logger.Error("account_reauth_required_write_failed", "account_id", value.ID, "provider", value.Provider, "error", err)
return err
}
return nil
}

// EnsureCredential 在即将过期时刷新 token,同一账号并发请求只执行一次刷新。
func (s *Service) EnsureCredential(ctx context.Context, value accountdomain.Credential, force bool) (accountdomain.Credential, error) {
return s.ensureCredential(ctx, value, force, false, false)
Expand Down Expand Up @@ -1878,7 +1894,7 @@ func (s *Service) refreshQuota(ctx context.Context, id uint64) ([]accountdomain.
snapshot, err := adapter.SyncQuota(ctx, value)
if err != nil {
if errors.Is(err, provider.ErrUnauthorized) {
_ = s.MarkReauthRequired(ctx, id, fmt.Sprintf("%s SSO credential rejected", value.Provider))
err = errors.Join(err, s.markSSOCredentialRejected(ctx, value, fmt.Sprintf("%s SSO credential rejected", value.Provider)))
}
return nil, err
}
Expand All @@ -1904,7 +1920,9 @@ func (s *Service) refreshQuota(ctx context.Context, id uint64) ([]accountdomain.
// 并沿用调用方取消语义,不能反向影响额度同步结果。
if (value.Provider == accountdomain.ProviderWeb || value.Provider == accountdomain.ProviderConsole) && ctx.Err() == nil {
if strings.TrimSpace(value.UserID) == "" && strings.TrimSpace(value.Email) == "" {
s.syncAccountIdentityBestEffort(ctx, id)
if identityErr := s.syncAccountIdentityBestEffort(ctx, id); errors.Is(identityErr, provider.ErrUnauthorized) {
return snapshot.Windows, identityErr
}
} else {
// 已有 Session 身份时只做本地增量关联,不再访问上游。
s.reconcileProviderLinksBestEffort(ctx, id)
Expand Down Expand Up @@ -1984,7 +2002,7 @@ func (s *Service) refreshQuotaMode(ctx context.Context, id uint64, mode string)
window, err := adapter.SyncQuotaMode(ctx, value, mode)
if err != nil {
if errors.Is(err, provider.ErrUnauthorized) {
_ = s.MarkReauthRequired(ctx, id, fmt.Sprintf("%s SSO credential rejected", value.Provider))
err = errors.Join(err, s.markSSOCredentialRejected(ctx, value, fmt.Sprintf("%s SSO credential rejected", value.Provider)))
}
return accountdomain.QuotaWindow{}, err
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,7 @@ func (s *Service) runWebAccountSetting(ctx context.Context, credential accountdo
return nil
}
if errors.Is(err, provider.ErrUnauthorized) {
_ = s.MarkReauthRequired(context.WithoutCancel(ctx), credential.ID, "Grok Web SSO credential rejected")
err = errors.Join(err, s.markSSOCredentialRejected(ctx, credential, "Grok Web SSO credential rejected"))
}
return fmt.Errorf("%s: %w", operation, err)
}
8 changes: 6 additions & 2 deletions backend/internal/application/accountsync/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -211,10 +211,14 @@ func (s *Service) syncAccount(ctx context.Context, accountID uint64) error {
if view.Credential.Provider == accountdomain.ProviderWeb || view.Credential.Provider == accountdomain.ProviderConsole {
if identity, ok := s.accounts.(identitySynchronizer); ok {
operationCtx, cancel := context.WithTimeout(ctx, operationTimeout)
if err := identity.SyncAccountIdentity(operationCtx, accountID); err != nil {
s.logger.Warn("account_initial_identity_sync_failed", "account_id", accountID, "error", err)
identityErr := identity.SyncAccountIdentity(operationCtx, accountID)
if identityErr != nil {
s.logger.Warn("account_initial_identity_sync_failed", "account_id", accountID, "error", identityErr)
}
cancel()
if errors.Is(identityErr, provider.ErrUnauthorized) {
return fmt.Errorf("同步账号身份: %w", identityErr)
}
}
}
if definition.Quota == provider.QuotaRemoteWindow || definition.Quota == provider.QuotaLocalWindow {
Expand Down
18 changes: 18 additions & 0 deletions backend/internal/application/accountsync/service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,24 @@ func TestSyncAccountIgnoresBestEffortSSOIdentityFailure(t *testing.T) {
}
}

func TestSyncAccountStopsAfterSSOIdentityUnauthorized(t *testing.T) {
for _, providerValue := range []accountdomain.Provider{accountdomain.ProviderWeb, accountdomain.ProviderConsole} {
t.Run(string(providerValue), func(t *testing.T) {
reader := &identityAccountReaderStub{accountReaderStub: accountReaderStub{provider: providerValue}, err: provider.ErrUnauthorized}
quota := &quotaStub{}
models := &modelStub{hasSnapshot: true}
service := NewService(slog.Default(), reader, &billingStub{}, quota, models)

if err := service.syncAccount(context.Background(), 10); !errors.Is(err, provider.ErrUnauthorized) {
t.Fatalf("err = %v", err)
}
if reader.calls != 1 || quota.syncs != 0 {
t.Fatalf("identity calls=%d quota syncs=%d", reader.calls, quota.syncs)
}
})
}
}

func TestSyncAccountUsesDeclaredQuotaPolicyInsteadOfProviderName(t *testing.T) {
billing := &billingStub{}
quota := &quotaStub{}
Expand Down
22 changes: 18 additions & 4 deletions backend/internal/application/gateway/image.go
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,14 @@ func (s *Service) executeImage(
response, err = execute(ctx, route.Provider, credential, route.UpstreamModel)
if err != nil {
s.logger.Error("image_upstream_failed", "event_id", eventID, "request_id", requestID, "model", externalModel, "provider", route.Provider, "account_id", credential.ID, "error", err)
if isSSOCredentialRejected(err, credential) {
s.markSSOCredentialRejected(ctx, credential, fmt.Sprintf("%s SSO credential rejected", credential.Provider))
failedCredential := credential
lastCredentialFailure = &failedCredential
lastCredentialError = provider.ErrUnauthorized
lease.Release()
continue
}
if !provider.IsMediaPostProcessingError(err) {
s.selector.MarkFailure(ctx, credential, 0, 0)
}
Expand All @@ -202,6 +210,16 @@ func (s *Service) executeImage(
writeFailureAudit(http.StatusBadGateway, errorCode, &credential)
return nil, err
}
if response.StatusCode == http.StatusUnauthorized && credential.AuthType == accountdomain.AuthTypeSSO {
_, _ = readRetryableBody(response.Body)
s.markSSOCredentialRejected(ctx, credential, fmt.Sprintf("%s SSO credential rejected", credential.Provider))
failedCredential := credential
lastCredentialFailure = &failedCredential
lastCredentialError = provider.ErrUnauthorized
response = nil
lease.Release()
continue
}
if s.providers.RetryForbiddenAsEgress(credential.Provider) && response.StatusCode == http.StatusForbidden && attempt == 0 && attempt+1 < attempts {
_, _ = readRetryableBody(response.Body)
lease.Release()
Expand Down Expand Up @@ -230,10 +248,6 @@ func (s *Service) executeImage(
}
return nil, fmt.Errorf("%w: %w", ErrNoAvailableAccount, lastCredentialError)
}
if response.StatusCode == http.StatusUnauthorized && credential.AuthType == accountdomain.AuthTypeSSO {
_ = s.accounts.MarkReauthRequired(ctx, credential.ID, fmt.Sprintf("%s SSO credential rejected", credential.Provider))
s.selector.MarkFailure(ctx, credential, http.StatusUnauthorized, 0)
}
effectiveQuotaMode := lease.QuotaMode
accountID := credential.ID
var once sync.Once
Expand Down
Loading
Loading