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
3 changes: 1 addition & 2 deletions backend/internal/accounts/repository.go
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,7 @@ func (r *SQLiteRepository) Update(account Account) error {

_, err = r.db.Exec(
`UPDATE accounts
SET account_name = ?, source_icon = ?, base_url = ?, credential_ref = ?, account_driver = ?, usage_driver = ?, usage_config_json = ?, status = ?, priority = ?, is_active = ?, is_locked = ?, supports_responses = ?, skip_tls_verify = ?, cooldown_until = ?, cooldown_reason = ?
SET account_name = ?, source_icon = ?, base_url = ?, credential_ref = ?, account_driver = ?, usage_driver = ?, usage_config_json = ?, status = ?, priority = ?, is_locked = ?, supports_responses = ?, skip_tls_verify = ?, cooldown_until = ?, cooldown_reason = ?
WHERE id = ?`,
account.AccountName,
account.SourceIcon,
Expand All @@ -216,7 +216,6 @@ func (r *SQLiteRepository) Update(account Account) error {
account.UsageConfigJSON,
account.Status,
account.Priority,
boolToInt(account.IsActive),
boolToInt(account.IsLocked),
boolToInt(account.SupportsResponses),
boolToInt(account.SkipTLSVerify),
Expand Down
62 changes: 62 additions & 0 deletions backend/internal/accounts/repository_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,68 @@ func TestSQLiteRepositorySetActiveKeepsSingleActiveAccount(t *testing.T) {
}
}

func TestSQLiteRepositoryUpdateDoesNotReactivateStaleAccount(t *testing.T) {
t.Parallel()

store, err := sqlitestore.Open(filepath.Join(t.TempDir(), "router.sqlite"))
if err != nil {
t.Fatalf("Open returned error: %v", err)
}
t.Cleanup(func() {
_ = store.Close()
})

repo := accounts.NewSQLiteRepository(store.DB())
for _, item := range []accounts.Account{
{ProviderType: accounts.ProviderOpenAICompatible, AccountName: "a", AuthMode: accounts.AuthModeAPIKey, CredentialRef: "sk-a", Priority: 2, Status: accounts.StatusActive},
{ProviderType: accounts.ProviderOpenAICompatible, AccountName: "b", AuthMode: accounts.AuthModeAPIKey, CredentialRef: "sk-b", Priority: 1, Status: accounts.StatusActive},
} {
if err := repo.Create(item); err != nil {
t.Fatalf("Create returned error: %v", err)
}
}

if err := repo.SetActive(1); err != nil {
t.Fatalf("SetActive(1) returned error: %v", err)
}
staleActive, err := repo.GetByID(1)
if err != nil {
t.Fatalf("GetByID returned error: %v", err)
}
if !staleActive.IsActive {
t.Fatal("stale account snapshot should start active")
}
if err := repo.SetActive(2); err != nil {
t.Fatalf("SetActive(2) returned error: %v", err)
}

staleActive.AccountName = "a-renamed"
if err := repo.Update(staleActive); err != nil {
t.Fatalf("Update returned error: %v", err)
}

items, err := repo.List()
if err != nil {
t.Fatalf("List returned error: %v", err)
}
if len(items) != 2 {
t.Fatalf("List returned %d items, want 2", len(items))
}
byID := map[int64]accounts.Account{}
for _, item := range items {
byID[item.ID] = item
}
if byID[1].AccountName != "a-renamed" {
t.Fatalf("account id=1 name = %q, want a-renamed", byID[1].AccountName)
}
if byID[1].IsActive {
t.Fatal("stale update reactivated account id=1")
}
if !byID[2].IsActive {
t.Fatal("account id=2 should remain active")
}
}

func TestSQLiteRepositoryPersistsAccountLockState(t *testing.T) {
t.Parallel()

Expand Down
40 changes: 34 additions & 6 deletions backend/internal/providers/codex/adapter.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import (
const (
// Codex ChatGPT backend gates some models (e.g. gpt-5.5) behind minimum Codex versions.
// Use a recent Codex CLI version string to avoid unnecessary model gating.
codexClientVersion = "0.125.0"
codexClientVersion = "0.142.5"
codexOriginator = "codex_cli_rs"
codexUserAgent = "codex_cli_rs/" + codexClientVersion + " (ai-gate)"
)
Expand Down Expand Up @@ -62,15 +62,12 @@ func (a *Adapter) BuildResponsesEndpointRequest(ctx context.Context, credential
}

func (a *Adapter) BuildUsageRequest(ctx context.Context, credential string, accountID string) (*http.Request, error) {
base, err := url.Parse(a.baseURL)
usageURL, err := a.buildUsageURL()
if err != nil {
return nil, err
}
base.Path = "/backend-api/wham/usage"
base.RawQuery = ""
base.Fragment = ""

req, err := http.NewRequestWithContext(ctx, http.MethodGet, base.String(), nil)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, usageURL, nil)
if err != nil {
return nil, err
}
Expand All @@ -82,6 +79,37 @@ func (a *Adapter) BuildUsageRequest(ctx context.Context, credential string, acco
return req, nil
}

func (a *Adapter) buildUsageURL() (string, error) {
base, err := url.Parse(a.baseURL)
if err != nil {
return "", err
}
base.RawQuery = ""
base.Fragment = ""

if isChatGPTUsageBase(base) {
base.Path = chatGPTBackendPath(base.Path) + "/wham/usage"
return base.String(), nil
}

base.Path = strings.TrimRight(base.Path, "/") + "/api/codex/usage"
return base.String(), nil
}

func isChatGPTUsageBase(base *url.URL) bool {
host := strings.ToLower(base.Hostname())
return host == "chatgpt.com" ||
host == "chat.openai.com" ||
strings.Contains(base.Path, "/backend-api")
}

func chatGPTBackendPath(path string) string {
if idx := strings.Index(path, "/backend-api"); idx >= 0 {
return path[:idx] + "/backend-api"
}
return "/backend-api"
}

func strconvTimeID() string {
return time.Now().UTC().Format("20060102T150405.000000000")
}
41 changes: 41 additions & 0 deletions backend/internal/providers/codex/adapter_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package codex

import (
"context"
"testing"
)

func TestAdapterBuildUsageRequestUsesLatestClientVersionAndChatGPTUsagePath(t *testing.T) {
t.Parallel()

req, err := NewAdapter("https://chatgpt.com/backend-api/codex").BuildUsageRequest(
context.Background(),
"token-1",
"acct-1",
)
if err != nil {
t.Fatalf("BuildUsageRequest returned error: %v", err)
}
if req.URL.String() != "https://chatgpt.com/backend-api/wham/usage" {
t.Fatalf("url = %q, want ChatGPT WHAM usage endpoint", req.URL.String())
}
if got := req.Header.Get("User-Agent"); got != "codex_cli_rs/0.142.5 (ai-gate)" {
t.Fatalf("User-Agent = %q, want latest Codex client version", got)
}
}

func TestAdapterBuildUsageRequestUsesCodexAPIPathForNonChatGPTBase(t *testing.T) {
t.Parallel()

req, err := NewAdapter("https://codex.example.test/root").BuildUsageRequest(
context.Background(),
"token-1",
"acct-1",
)
if err != nil {
t.Fatalf("BuildUsageRequest returned error: %v", err)
}
if req.URL.String() != "https://codex.example.test/root/api/codex/usage" {
t.Fatalf("url = %q, want Codex API usage endpoint", req.URL.String())
}
}
12 changes: 12 additions & 0 deletions backend/internal/store/sqlite/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,18 @@ func (s *Store) migrate() error {
if _, err := s.db.Exec(`UPDATE accounts SET status = 'active' WHERE status = 'cooldown'`); err != nil {
return fmt.Errorf("normalize account cooldown status: %w", err)
}
if _, err := s.db.Exec(`UPDATE accounts
SET is_active = 0
WHERE is_active = 1
AND id NOT IN (
SELECT id
FROM accounts
WHERE is_active = 1
ORDER BY priority DESC, id ASC
LIMIT 1
)`); err != nil {
return fmt.Errorf("normalize duplicate active accounts: %w", err)
}
if _, err := s.db.Exec(`UPDATE server_users SET username = name WHERE username = ''`); err != nil {
return fmt.Errorf("backfill server user usernames: %w", err)
}
Expand Down
43 changes: 43 additions & 0 deletions backend/internal/store/sqlite/store_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,49 @@ func TestOpenAddsSnapshotMetadataColumns(t *testing.T) {
}
}

func TestOpenNormalizesDuplicateActiveAccounts(t *testing.T) {
t.Parallel()

dbPath := filepath.Join(t.TempDir(), "codex-router.sqlite")

store, err := sqlite.Open(dbPath)
if err != nil {
t.Fatalf("Open returned error: %v", err)
}
if _, err := store.DB().Exec(`INSERT INTO accounts (provider_type, account_name, source_icon, auth_mode, credential_ref, base_url, status, priority, is_active, supports_responses)
VALUES
('openai-compatible', 'primary', 'openai', 'api_key', 'secret-a', 'https://a.example.test/v1', 'active', 10, 1, 1),
('openai-compatible', 'secondary', 'openai', 'api_key', 'secret-b', 'https://b.example.test/v1', 'active', 20, 1, 1)`); err != nil {
t.Fatalf("seed duplicate active accounts returned error: %v", err)
}
if err := store.Close(); err != nil {
t.Fatalf("Close returned error: %v", err)
}

reopened, err := sqlite.Open(dbPath)
if err != nil {
t.Fatalf("reopen returned error: %v", err)
}
t.Cleanup(func() {
_ = reopened.Close()
})

var activeCount int
if err := reopened.DB().QueryRow(`SELECT COUNT(*) FROM accounts WHERE is_active = 1`).Scan(&activeCount); err != nil {
t.Fatalf("count active accounts returned error: %v", err)
}
if activeCount != 1 {
t.Fatalf("active accounts = %d, want 1", activeCount)
}
var activeName string
if err := reopened.DB().QueryRow(`SELECT account_name FROM accounts WHERE is_active = 1`).Scan(&activeName); err != nil {
t.Fatalf("select active account returned error: %v", err)
}
if activeName != "secondary" {
t.Fatalf("active account = %q, want highest-priority secondary", activeName)
}
}

func TestOpenConfiguresSQLiteForBusyDesktopWorkload(t *testing.T) {
t.Parallel()

Expand Down
93 changes: 92 additions & 1 deletion backend/internal/usagedrv/builtin/builtin_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"net/http/httptest"
"strings"
"testing"
"time"

"github.com/gcssloop/codex-router/backend/internal/accountdrv"
"github.com/gcssloop/codex-router/backend/internal/accounts"
Expand All @@ -33,7 +34,7 @@ func TestOpenAIOfficialDriverFetchParsesUsageLimits(t *testing.T) {
driver := builtin.NewOpenAIOfficialDriver(http.DefaultClient)
result, err := driver.Fetch(context.Background(), accounts.Account{
ProviderType: accounts.ProviderOpenAIOfficial,
BaseURL: server.URL,
BaseURL: server.URL + "/backend-api/codex",
}, accountdrv.ResolvedCredential{
AccessToken: "at-token",
Metadata: map[string]any{"account_id": "acct-1"},
Expand Down Expand Up @@ -67,6 +68,96 @@ func TestOpenAIOfficialDriverFetchParsesUsageLimits(t *testing.T) {
}
}

func TestOpenAIOfficialDriverFetchParsesCurrentCodexRateLimits(t *testing.T) {
t.Parallel()

server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/backend-api/wham/usage" {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"plan_type":"pro",
"rate_limit":{
"allowed":true,
"limit_reached":false,
"primary_window":{
"used_percent":52,
"limit_window_seconds":18000,
"reset_after_seconds":3600,
"reset_at":1767205800
},
"secondary_window":{
"used_percent":51,
"limit_window_seconds":604800,
"reset_after_seconds":86400,
"reset_at":1783350300
}
},
"credits":{"has_credits":true,"unlimited":false,"balance":"0"},
"additional_rate_limits":[
{
"limit_name":"codex_other",
"metered_feature":"codex_other",
"rate_limit":{
"allowed":true,
"limit_reached":false,
"primary_window":{
"used_percent":88,
"limit_window_seconds":1800,
"reset_after_seconds":600,
"reset_at":1735693200
}
}
}
],
"rate_limit_reached_type":{"type":"workspace_member_usage_limit_reached"},
"rate_limit_reset_credits":{"available_count":1}
}`))
}))
defer server.Close()

driver := builtin.NewOpenAIOfficialDriver(http.DefaultClient)
result, err := driver.Fetch(context.Background(), accounts.Account{
ProviderType: accounts.ProviderOpenAIOfficial,
BaseURL: server.URL + "/backend-api/codex",
}, accountdrv.ResolvedCredential{
AccessToken: "at-token",
Metadata: map[string]any{"account_id": "acct-1"},
})
if err != nil {
t.Fatalf("Fetch returned error: %v", err)
}
if result.Limits.PrimaryUsedPercent == nil || *result.Limits.PrimaryUsedPercent != 52 {
t.Fatalf("PrimaryUsedPercent = %#v, want 52", result.Limits.PrimaryUsedPercent)
}
if result.Limits.SecondaryUsedPercent == nil || *result.Limits.SecondaryUsedPercent != 51 {
t.Fatalf("SecondaryUsedPercent = %#v, want 51", result.Limits.SecondaryUsedPercent)
}
if result.Limits.RPMRemaining == nil || *result.Limits.RPMRemaining != 48 {
t.Fatalf("RPMRemaining = %#v, want 48", result.Limits.RPMRemaining)
}
if result.Limits.TPMRemaining == nil || *result.Limits.TPMRemaining != 49 {
t.Fatalf("TPMRemaining = %#v, want 49", result.Limits.TPMRemaining)
}
wantPrimaryReset := time.Unix(1767205800, 0).UTC()
if result.Limits.PrimaryResetsAt == nil || !result.Limits.PrimaryResetsAt.Equal(wantPrimaryReset) {
t.Fatalf("PrimaryResetsAt = %#v, want %s", result.Limits.PrimaryResetsAt, wantPrimaryReset)
}
wantSecondaryReset := time.Unix(1783350300, 0).UTC()
if result.Limits.SecondaryResetsAt == nil || !result.Limits.SecondaryResetsAt.Equal(wantSecondaryReset) {
t.Fatalf("SecondaryResetsAt = %#v, want %s", result.Limits.SecondaryResetsAt, wantSecondaryReset)
}
if got, ok := result.Meta["plan_type"].(string); !ok || got != "pro" {
t.Fatalf("meta.plan_type = %#v, want pro", result.Meta["plan_type"])
}
reachedType, ok := result.Meta["rate_limit_reached_type"].(map[string]any)
if !ok || reachedType["type"] != "workspace_member_usage_limit_reached" {
t.Fatalf("meta.rate_limit_reached_type = %#v, want workspace_member_usage_limit_reached", result.Meta["rate_limit_reached_type"])
}
}

func TestOpenAIOfficialDriverFetchClassifiesErrors(t *testing.T) {
t.Parallel()

Expand Down
Loading
Loading