diff --git a/README.md b/README.md
index 1b4f927ad..183f2a0c3 100644
--- a/README.md
+++ b/README.md
@@ -23,6 +23,7 @@ Run it as a full **PostgreSQL + Redis** production stack or as a single-containe
One compatible gateway OpenAI-style Chat Completions / Responses / Images, Anthropic Messages, prefixless compatibility routes, and native Codex Responses forwarding are all exposed through one service.
+Named upstream providers Beyond Codex OAuth accounts, pool OpenAI-Responses-compatible gateway credentials as first-class upstreams — including a dedicated OrcaRouter provider type (sk-orca- keys, Base URL https://api.orcarouter.ai/v1) managed from the Accounts page.
Account-pool scheduler Selection is driven by account status, health tier, scheduler score, dynamic concurrency, cooldown recovery, and recent usage so unhealthy accounts are avoided automatically. Supports round_robin and remaining_quota modes, with per-account credit billing flags.
Visual admin console The embedded React / Vite dashboard covers account import and testing, API keys, proxy pools, image studio (text-to-image + image-to-image), prompt filtering, usage analytics, operations, scheduler board, and system settings.
Two deployment shapes Use PostgreSQL + Redis for production or SQLite + Memory for lightweight single-node deployments; Docker images, source builds, local development, and the interactive deploy script are ready to use. SQLite mode binds to 127.0.0.1 by default for security.
@@ -365,6 +366,31 @@ curl -X POST http://localhost:8080/api/admin/accounts/at \
-d '{"access_token": "eyJtoken1...\neyJtoken2...\neyJtoken3..."}'
```
+#### Add OrcaRouter Gateway Accounts
+
+Codex2API can also pool **OrcaRouter** gateway credentials as a first-class upstream type. [OrcaRouter](https://www.orcarouter.ai) is an OpenAI-Responses compatible gateway: add a `sk-orca-` API key and a Base URL, list the models it exposes, and the pool will route `/v1/responses` traffic through it with the same scheduling, health scoring, and usage tracking as Codex OAuth accounts. It also runs gateway-level, zero-trust security for AI agents on the same endpoint — screening every prompt/response and governing every tool call on a default-deny basis, with no application code changes.
+
+```bash
+# Add an OrcaRouter gateway account
+curl -X POST http://localhost:8080/api/admin/accounts/orcarouter \
+ -H "X-Admin-Key: your-admin-secret" \
+ -H "Content-Type: application/json" \
+ -d '{
+ "name": "orcarouter-pool",
+ "base_url": "https://api.orcarouter.ai/v1",
+ "api_key": "sk-orca-xxxxxxxxxxxx",
+ "models": ["orcarouter/auto"]
+ }'
+
+# Fetch the model catalog exposed by a Base URL + key
+curl -X POST http://localhost:8080/api/admin/accounts/orcarouter/models \
+ -H "X-Admin-Key: your-admin-secret" \
+ -H "Content-Type: application/json" \
+ -d '{"base_url": "https://api.orcarouter.ai/v1", "api_key": "sk-orca-xxxxxxxxxxxx"}'
+```
+
+OrcaRouter accounts can be managed from the Accounts page like any other upstream: add via the **OrcaRouter** tab, edit Base URL / model whitelist, test the connection, and monitor usage. The `platform` column is marked `orcarouter` so they are easy to identify in the admin dashboard.
+
#### File Import
```bash
diff --git a/README.zh-CN.md b/README.zh-CN.md
index daa7b1707..fb64c4c13 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -412,6 +412,31 @@ curl -X POST http://localhost:8080/api/admin/accounts/at \
-d '{"access_token": "eyJtoken1...\neyJtoken2...\neyJtoken3..."}'
```
+#### 添加 OrcaRouter 网关账号
+
+Codex2API 也可以把 **OrcaRouter** 网关凭据作为一等上游类型加入账号池。[OrcaRouter](https://www.orcarouter.ai) 是 OpenAI-Responses 兼容网关:填入 `sk-orca-` API Key 与 Base URL,拉取它暴露的模型清单,账号池就会用与 Codex OAuth 账号相同的调度、健康评分与用量追踪来路由 `/v1/responses` 流量。它在同一端点还运行网关级、零信任的 AI Agent 安全能力——默认拒绝地审查每一条 prompt/response 并治理每一次工具调用,无需改动应用代码。
+
+```bash
+# 添加一个 OrcaRouter 网关账号
+curl -X POST http://localhost:8080/api/admin/accounts/orcarouter \
+ -H "X-Admin-Key: your-admin-secret" \
+ -H "Content-Type: application/json" \
+ -d '{
+ "name": "orcarouter-pool",
+ "base_url": "https://api.orcarouter.ai/v1",
+ "api_key": "sk-orca-xxxxxxxxxxxx",
+ "models": ["orcarouter/auto"]
+ }'
+
+# 拉取 Base URL + key 暴露的模型目录
+curl -X POST http://localhost:8080/api/admin/accounts/orcarouter/models \
+ -H "X-Admin-Key: your-admin-secret" \
+ -H "Content-Type: application/json" \
+ -d '{"base_url": "https://api.orcarouter.ai/v1", "api_key": "sk-orca-xxxxxxxxxxxx"}'
+```
+
+OrcaRouter 账号可以在 Accounts 页像其他上游一样管理:通过 **OrcaRouter** 标签添加、编辑 Base URL / 模型白名单、测试连通性并查看用量。`platform` 列标记为 `orcarouter`,便于在管理后台识别。
+
#### 文件批量导入
```bash
diff --git a/admin/account_response_builder.go b/admin/account_response_builder.go
index 8787c6bee..83e68452f 100644
--- a/admin/account_response_builder.go
+++ b/admin/account_response_builder.go
@@ -25,6 +25,8 @@ func (h *Handler) buildAccountResponse(
) accountResponse {
upstreamType := strings.TrimSpace(row.GetCredential("upstream_type"))
isOpenAIResponsesAccount := strings.EqualFold(upstreamType, auth.UpstreamOpenAIResponses)
+ isOrcaRouterAccount := strings.EqualFold(upstreamType, auth.UpstreamOrcaRouter)
+ isRelayResponsesAccount := isOpenAIResponsesAccount || isOrcaRouterAccount
isGrokAccount := strings.EqualFold(upstreamType, auth.UpstreamGrok)
grokAuthKind := ""
var grokBilling json.RawMessage
@@ -43,11 +45,11 @@ func (h *Handler) buildAccountResponse(
}
email := row.GetCredential("email")
baseURL := row.GetCredential("base_url")
- if isOpenAIResponsesAccount && email == "" {
+ if isRelayResponsesAccount && email == "" {
email = baseURL
}
planType := row.GetCredential("plan_type")
- if isOpenAIResponsesAccount && planType == "" {
+ if isRelayResponsesAccount && planType == "" {
planType = "api"
}
if isGrokAccount && grokAuthKind == auth.GrokAuthKindAPIKey {
@@ -65,12 +67,12 @@ func (h *Handler) buildAccountResponse(
}
}
codexClientMetadataMode := ""
- if isOpenAIResponsesAccount && includeDetails {
+ if isRelayResponsesAccount && includeDetails {
codexClientMetadataMode = auth.NormalizeCodexClientMetadataMode(row.GetCredential("codex_client_metadata_mode"))
}
// 指纹收敛只作用于 Codex 官方出站路径,中转/Grok 账号不暴露该字段。
codexFingerprintMode := ""
- if !isOpenAIResponsesAccount && !isGrokAccount {
+ if !isRelayResponsesAccount && !isGrokAccount {
codexFingerprintMode = auth.NormalizeCodexFingerprintMode(row.GetCredential(auth.CodexFingerprintModeCredentialKey))
}
ignoreUsageLimitStatusOverride := row.GetCredentialOptionalBool("ignore_usage_limit_status_override")
@@ -107,13 +109,14 @@ func (h *Handler) buildAccountResponse(
SubscriptionExpiresAt: row.GetCredential("subscription_expires_at"),
Status: row.Status,
ErrorMessage: row.ErrorMessage,
- ATOnly: !isOpenAIResponsesAccount && !isGrokAccount && row.GetCredential("refresh_token") == "" && row.GetCredential("access_token") != "",
+ ATOnly: !isRelayResponsesAccount && !isGrokAccount && row.GetCredential("refresh_token") == "" && row.GetCredential("access_token") != "",
CreditEnabled: row.CreditEnabled,
CreditSkipUsageWindow: row.CreditSkipUsageWindow,
SkipWarmTier: row.SkipWarmTier,
AccountType: row.Type,
AccessTokenType: accountAccessTokenType(row),
OpenAIResponsesAPI: isOpenAIResponsesAccount,
+ OrcaRouterAPI: isOrcaRouterAccount,
GrokAPI: isGrokAccount,
AgentIdentity: isAgentIdentityCredentialRow(row),
GrokAuthKind: grokAuthKind,
diff --git a/admin/accounts_paged.go b/admin/accounts_paged.go
index a0c5fd464..ef32f1887 100644
--- a/admin/accounts_paged.go
+++ b/admin/accounts_paged.go
@@ -676,7 +676,7 @@ func (h *Handler) installAccountListSnapshot(channel string, snapshot *accountLi
func (h *Handler) buildAccountListSnapshotItem(row *database.AccountRow, requestCounts map[int64]*database.AccountRequestCount, todayUsage map[int64]*database.AccountTimeRangeUsage, groupNames, groupSort map[int64]string) *accountListSnapshotItem {
upstreamType := strings.TrimSpace(row.GetCredential("upstream_type"))
isGrok := strings.EqualFold(upstreamType, auth.UpstreamGrok)
- isOpenAIResponses := strings.EqualFold(upstreamType, auth.UpstreamOpenAIResponses)
+ isOpenAIResponses := strings.EqualFold(upstreamType, auth.UpstreamOpenAIResponses) || strings.EqualFold(upstreamType, auth.UpstreamOrcaRouter)
email := row.GetCredential("email")
if isOpenAIResponses && email == "" {
email = row.GetCredential("base_url")
diff --git a/admin/handler.go b/admin/handler.go
index db54a7937..a9f2184fe 100644
--- a/admin/handler.go
+++ b/admin/handler.go
@@ -1020,6 +1020,9 @@ func (h *Handler) RegisterRoutes(r *gin.Engine) {
api.POST("/accounts/openai-responses", h.AddOpenAIResponsesAccount)
api.POST("/accounts/openai-responses/models", h.FetchOpenAIResponsesModels)
api.PATCH("/accounts/:id/openai-responses", h.UpdateOpenAIResponsesAccount)
+ api.POST("/accounts/orcarouter", h.AddOrcaRouterAccount)
+ api.POST("/accounts/orcarouter/models", h.FetchOrcaRouterModels)
+ api.PATCH("/accounts/:id/orcarouter", h.UpdateOrcaRouterAccount)
api.POST("/accounts/grok", h.AddGrokAccount)
api.POST("/accounts/grok/models", h.FetchGrokModels)
api.POST("/accounts/grok/batch-models", h.BatchUpdateGrokModels)
@@ -1404,7 +1407,7 @@ func isDashboardUnsampledAccount(row *database.AccountRow, acc *auth.Account) bo
return false
}
upstreamType := strings.TrimSpace(row.GetCredential("upstream_type"))
- if strings.EqualFold(upstreamType, auth.UpstreamGrok) || strings.EqualFold(upstreamType, auth.UpstreamOpenAIResponses) {
+ if strings.EqualFold(upstreamType, auth.UpstreamGrok) || strings.EqualFold(upstreamType, auth.UpstreamOpenAIResponses) || strings.EqualFold(upstreamType, auth.UpstreamOrcaRouter) {
return false
}
status := strings.ToLower(strings.TrimSpace(row.Status))
@@ -1452,6 +1455,7 @@ type accountResponse struct {
AccountType string `json:"account_type,omitempty"`
AccessTokenType string `json:"access_token_type,omitempty"`
OpenAIResponsesAPI bool `json:"openai_responses_api,omitempty"`
+ OrcaRouterAPI bool `json:"orcarouter_api,omitempty"`
GrokAPI bool `json:"grok_api,omitempty"`
AgentIdentity bool `json:"agent_identity,omitempty"`
GrokAuthKind string `json:"grok_auth_kind,omitempty"`
@@ -1824,6 +1828,7 @@ type accountLiteResponse struct {
ProxyURL string `json:"proxy_url"`
ATOnly bool `json:"at_only"`
OpenAIResponsesAPI bool `json:"openai_responses_api"`
+ OrcaRouterAPI bool `json:"orcarouter_api"`
GrokAPI bool `json:"grok_api"`
AgentIdentity bool `json:"agent_identity"`
GrokAuthKind string `json:"grok_auth_kind,omitempty"`
@@ -1847,6 +1852,8 @@ func (h *Handler) listAccountsLite(c *gin.Context, ctx context.Context) {
for _, row := range rows {
upstreamType := strings.TrimSpace(row.GetCredential("upstream_type"))
isOpenAIResponsesAccount := strings.EqualFold(upstreamType, auth.UpstreamOpenAIResponses)
+ isOrcaRouterAccount := strings.EqualFold(upstreamType, auth.UpstreamOrcaRouter)
+ isRelayResponsesAccount := isOpenAIResponsesAccount || isOrcaRouterAccount
isGrokAccount := strings.EqualFold(upstreamType, auth.UpstreamGrok)
grokAuthKind := ""
if isGrokAccount {
@@ -1857,11 +1864,11 @@ func (h *Handler) listAccountsLite(c *gin.Context, ctx context.Context) {
}
}
email := row.GetCredential("email")
- if isOpenAIResponsesAccount && email == "" {
+ if isRelayResponsesAccount && email == "" {
email = row.GetCredential("base_url")
}
planType := row.GetCredential("plan_type")
- if (isOpenAIResponsesAccount || (isGrokAccount && grokAuthKind == auth.GrokAuthKindAPIKey)) && planType == "" {
+ if (isRelayResponsesAccount || (isGrokAccount && grokAuthKind == auth.GrokAuthKindAPIKey)) && planType == "" {
planType = "api"
}
status := row.Status
@@ -1876,8 +1883,9 @@ func (h *Handler) listAccountsLite(c *gin.Context, ctx context.Context) {
Status: status,
Enabled: row.Enabled,
ProxyURL: row.ProxyURL,
- ATOnly: !isOpenAIResponsesAccount && !isGrokAccount && row.GetCredential("refresh_token") == "" && row.GetCredential("access_token") != "",
+ ATOnly: !isRelayResponsesAccount && !isGrokAccount && row.GetCredential("refresh_token") == "" && row.GetCredential("access_token") != "",
OpenAIResponsesAPI: isOpenAIResponsesAccount,
+ OrcaRouterAPI: isOrcaRouterAccount,
GrokAPI: isGrokAccount,
AgentIdentity: isAgentIdentityCredentialRow(row),
GrokAuthKind: grokAuthKind,
@@ -3584,6 +3592,16 @@ type fetchOpenAIResponsesModelsReq struct {
}
func (h *Handler) AddOpenAIResponsesAccount(c *gin.Context) {
+ h.addResponsesAccount(c, auth.UpstreamOpenAIResponses, "openai-responses", "manual_openai_responses", "OPENAI_RESPONSES_ACCOUNT_ADDED", "成功添加 OpenAI Responses API 账号")
+}
+
+func (h *Handler) AddOrcaRouterAccount(c *gin.Context) {
+ h.addResponsesAccount(c, auth.UpstreamOrcaRouter, "orcarouter", "manual_orcarouter", "ORCAROUTER_ACCOUNT_ADDED", "成功添加 OrcaRouter 网关账号")
+}
+
+// addResponsesAccount 添加一个 OpenAI-Responses 兼容上游账号。openai_responses 与
+// orcarouter 共用同一套校验与存储逻辑,仅 upstream_type / 命名 / 审计日志不同。
+func (h *Handler) addResponsesAccount(c *gin.Context, upstreamType, defaultName, eventAction, auditEvent, successMsg string) {
var req addOpenAIResponsesAccountReq
if err := c.ShouldBindJSON(&req); err != nil {
writeError(c, http.StatusBadRequest, "请求格式错误")
@@ -3660,10 +3678,10 @@ func (h *Handler) AddOpenAIResponsesAccount(c *gin.Context) {
name := req.Name
if name == "" {
- name = "openai-responses"
+ name = defaultName
}
credentials := map[string]interface{}{
- "upstream_type": auth.UpstreamOpenAIResponses,
+ "upstream_type": upstreamType,
"base_url": baseURL,
"api_key": req.APIKey,
"models": models,
@@ -3675,18 +3693,23 @@ func (h *Handler) AddOpenAIResponsesAccount(c *gin.Context) {
if len(customHeaders) > 0 {
credentials["custom_headers"] = cloneCustomHeaders(customHeaders)
}
- id, err := h.db.InsertOpenAIResponsesAccount(ctx, name, credentials, req.ProxyURL)
+ var id int64
+ if upstreamType == auth.UpstreamOrcaRouter {
+ id, err = h.db.InsertOrcaRouterAccount(ctx, name, credentials, req.ProxyURL)
+ } else {
+ id, err = h.db.InsertOpenAIResponsesAccount(ctx, name, credentials, req.ProxyURL)
+ }
if err != nil {
writeInternalError(c, err)
return
}
- h.db.InsertAccountEventAsync(id, "added", "manual_openai_responses")
+ h.db.InsertAccountEventAsync(id, "added", eventAction)
h.store.AddAccount(&auth.Account{
DBID: id,
ProxyURL: req.ProxyURL,
HealthTier: auth.HealthTierHealthy,
- UpstreamType: auth.UpstreamOpenAIResponses,
+ UpstreamType: upstreamType,
BaseURL: baseURL,
APIKey: req.APIKey,
Models: models,
@@ -3697,14 +3720,22 @@ func (h *Handler) AddOpenAIResponsesAccount(c *gin.Context) {
PlanType: "api",
})
- security.SecurityAuditLog("OPENAI_RESPONSES_ACCOUNT_ADDED", fmt.Sprintf("account_id=%d models=%d ip=%s", id, len(models), c.ClientIP()))
+ security.SecurityAuditLog(auditEvent, fmt.Sprintf("account_id=%d models=%d ip=%s", id, len(models), c.ClientIP()))
c.JSON(http.StatusOK, gin.H{
- "message": "成功添加 OpenAI Responses API 账号",
+ "message": successMsg,
"id": id,
})
}
func (h *Handler) FetchOpenAIResponsesModels(c *gin.Context) {
+ h.fetchResponsesModels(c, auth.UpstreamOpenAIResponses)
+}
+
+func (h *Handler) FetchOrcaRouterModels(c *gin.Context) {
+ h.fetchResponsesModels(c, auth.UpstreamOrcaRouter)
+}
+
+func (h *Handler) fetchResponsesModels(c *gin.Context, upstreamType string) {
var req fetchOpenAIResponsesModelsReq
if err := c.ShouldBindJSON(&req); err != nil {
writeError(c, http.StatusBadRequest, "请求格式错误")
@@ -3723,8 +3754,8 @@ func (h *Handler) FetchOpenAIResponsesModels(c *gin.Context) {
writeInternalError(c, err)
return
}
- if !strings.EqualFold(strings.TrimSpace(row.GetCredential("upstream_type")), auth.UpstreamOpenAIResponses) {
- writeError(c, http.StatusBadRequest, "仅 OpenAI Responses API 账号支持使用已保存的 API Key 获取模型")
+ if !strings.EqualFold(strings.TrimSpace(row.GetCredential("upstream_type")), upstreamType) {
+ writeError(c, http.StatusBadRequest, "该上游账号不支持使用已保存的 API Key 获取模型")
return
}
req.APIKey = row.GetCredential("api_key")
@@ -3771,6 +3802,14 @@ func (h *Handler) FetchOpenAIResponsesModels(c *gin.Context) {
}
func (h *Handler) UpdateOpenAIResponsesAccount(c *gin.Context) {
+ h.updateResponsesAccount(c, auth.UpstreamOpenAIResponses, "openai-responses", "manual_openai_responses", "OpenAI Responses API 账号设置已更新")
+}
+
+func (h *Handler) UpdateOrcaRouterAccount(c *gin.Context) {
+ h.updateResponsesAccount(c, auth.UpstreamOrcaRouter, "orcarouter", "manual_orcarouter", "OrcaRouter 网关账号设置已更新")
+}
+
+func (h *Handler) updateResponsesAccount(c *gin.Context, upstreamType, defaultName, eventAction, successMsg string) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
writeError(c, http.StatusBadRequest, "无效的账号 ID")
@@ -3797,8 +3836,8 @@ func (h *Handler) UpdateOpenAIResponsesAccount(c *gin.Context) {
writeInternalError(c, err)
return
}
- if !strings.EqualFold(strings.TrimSpace(row.GetCredential("upstream_type")), auth.UpstreamOpenAIResponses) {
- writeError(c, http.StatusBadRequest, "仅 OpenAI Responses API 账号支持账号设置")
+ if !strings.EqualFold(strings.TrimSpace(row.GetCredential("upstream_type")), upstreamType) {
+ writeError(c, http.StatusBadRequest, "仅匹配的上游账号支持账号设置")
return
}
@@ -3854,11 +3893,11 @@ func (h *Handler) UpdateOpenAIResponsesAccount(c *gin.Context) {
name = row.Name
}
if name == "" {
- name = "openai-responses"
+ name = defaultName
}
credentials := map[string]interface{}{
- "upstream_type": auth.UpstreamOpenAIResponses,
+ "upstream_type": upstreamType,
"base_url": baseURL,
"models": models,
"model_mapping": modelMapping,
@@ -3875,7 +3914,12 @@ func (h *Handler) UpdateOpenAIResponsesAccount(c *gin.Context) {
return
}
- if err := h.db.UpdateOpenAIResponsesAccount(ctx, id, name, credentials, req.ProxyURL); err != nil {
+ if upstreamType == auth.UpstreamOrcaRouter {
+ err = h.db.UpdateOrcaRouterAccount(ctx, id, name, credentials, req.ProxyURL)
+ } else {
+ err = h.db.UpdateOpenAIResponsesAccount(ctx, id, name, credentials, req.ProxyURL)
+ }
+ if err != nil {
if errors.Is(err, sql.ErrNoRows) {
writeError(c, http.StatusNotFound, "账号不存在")
return
@@ -3887,9 +3931,9 @@ func (h *Handler) UpdateOpenAIResponsesAccount(c *gin.Context) {
h.store.ApplyOpenAIResponsesConfig(id, baseURL, req.APIKey, models, modelMapping, codexClientMetadataMode, req.ProxyURL)
h.store.ApplyAccountCustomHeaders(id, customHeaders)
}
- h.db.InsertAccountEventAsync(id, "updated", "manual_openai_responses")
+ h.db.InsertAccountEventAsync(id, "updated", eventAction)
- writeMessage(c, http.StatusOK, "OpenAI Responses API 账号设置已更新")
+ writeMessage(c, http.StatusOK, successMsg)
}
func fetchOpenAIResponsesModelIDs(ctx context.Context, baseURL, apiKey, proxyURL string, customHeaders map[string]string) ([]string, error) {
@@ -5532,6 +5576,7 @@ type recycleBinAccountResponse struct {
ATOnly bool `json:"at_only"`
AccessTokenType string `json:"access_token_type,omitempty"`
OpenAIResponsesAPI bool `json:"openai_responses_api"`
+ OrcaRouterAPI bool `json:"orcarouter_api"`
BaseURL string `json:"base_url,omitempty"`
Models []string `json:"models,omitempty"`
CreatedAt string `json:"created_at"`
@@ -5554,14 +5599,17 @@ func (h *Handler) ListRecycleBinAccounts(c *gin.Context) {
accounts := make([]recycleBinAccountResponse, 0, len(rows))
for _, row := range rows {
- isOpenAIResponsesAccount := strings.EqualFold(strings.TrimSpace(row.GetCredential("upstream_type")), auth.UpstreamOpenAIResponses)
+ upstreamType := strings.TrimSpace(row.GetCredential("upstream_type"))
+ isOpenAIResponsesAccount := strings.EqualFold(upstreamType, auth.UpstreamOpenAIResponses)
+ isOrcaRouterAccount := strings.EqualFold(upstreamType, auth.UpstreamOrcaRouter)
+ isRelayResponsesAccount := isOpenAIResponsesAccount || isOrcaRouterAccount
email := row.GetCredential("email")
baseURL := row.GetCredential("base_url")
- if isOpenAIResponsesAccount && email == "" {
+ if isRelayResponsesAccount && email == "" {
email = baseURL
}
planType := row.GetCredential("plan_type")
- if isOpenAIResponsesAccount && planType == "" {
+ if isRelayResponsesAccount && planType == "" {
planType = "api"
}
resp := recycleBinAccountResponse{
@@ -5569,9 +5617,10 @@ func (h *Handler) ListRecycleBinAccounts(c *gin.Context) {
Name: row.Name,
Email: email,
PlanType: planType,
- ATOnly: !isOpenAIResponsesAccount && row.GetCredential("refresh_token") == "" && row.GetCredential("access_token") != "",
+ ATOnly: !isRelayResponsesAccount && row.GetCredential("refresh_token") == "" && row.GetCredential("access_token") != "",
AccessTokenType: accountAccessTokenType(row),
OpenAIResponsesAPI: isOpenAIResponsesAccount,
+ OrcaRouterAPI: isOrcaRouterAccount,
BaseURL: baseURL,
Models: row.GetCredentialStringSlice("models"),
CreatedAt: row.CreatedAt.Format(time.RFC3339),
diff --git a/admin/orcarouter_test.go b/admin/orcarouter_test.go
new file mode 100644
index 000000000..6a1e34166
--- /dev/null
+++ b/admin/orcarouter_test.go
@@ -0,0 +1,120 @@
+package admin
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/codex2api/auth"
+ "github.com/codex2api/cache"
+ "github.com/codex2api/database"
+ "github.com/gin-gonic/gin"
+)
+
+func newOrcaRouterTestHandler(t *testing.T) (*Handler, *database.DB) {
+ t.Helper()
+ gin.SetMode(gin.TestMode)
+ db, err := database.New("sqlite", filepath.Join(t.TempDir(), "orcarouter-handler.db"))
+ if err != nil {
+ t.Fatalf("database.New: %v", err)
+ }
+ store := auth.NewStore(db, cache.NewMemory(1), &database.SystemSettings{TestModel: "orcarouter/auto"})
+ return &Handler{db: db, store: store}, db
+}
+
+func TestAddOrcaRouterAccountHandler(t *testing.T) {
+ handler, db := newOrcaRouterTestHandler(t)
+ defer db.Close()
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/v1/models" {
+ t.Fatalf("path = %q, want /v1/models", r.URL.Path)
+ }
+ if got := r.Header.Get("Authorization"); got != "Bearer sk-orca-test" {
+ t.Fatalf("Authorization = %q", got)
+ }
+ _, _ = w.Write([]byte(`{"data":[{"id":"orcarouter/auto"},{"id":"orcarouter/fusion"}]}`))
+ }))
+ defer server.Close()
+
+ // Fetch models through the OrcaRouter endpoint first.
+ body := `{"base_url":"` + server.URL + `/v1","api_key":"sk-orca-test"}`
+ recorder := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(recorder)
+ c.Request = httptest.NewRequest(http.MethodPost, "/api/admin/accounts/orcarouter/models", strings.NewReader(body))
+ c.Request.Header.Set("Content-Type", "application/json")
+ handler.FetchOrcaRouterModels(c)
+ if recorder.Code != http.StatusOK {
+ t.Fatalf("FetchOrcaRouterModels status = %d, body=%s", recorder.Code, recorder.Body.String())
+ }
+ var fetched struct {
+ Models []string `json:"models"`
+ }
+ if err := json.Unmarshal(recorder.Body.Bytes(), &fetched); err != nil {
+ t.Fatalf("decode fetch response: %v", err)
+ }
+ if len(fetched.Models) != 2 || fetched.Models[0] != "orcarouter/auto" {
+ t.Fatalf("fetched models = %#v", fetched.Models)
+ }
+
+ // Add the account via the OrcaRouter route.
+ body = `{"name":"orca","base_url":"` + server.URL + `/v1","api_key":"sk-orca-test","models":["orcarouter/auto","orcarouter/fusion"]}`
+ recorder = httptest.NewRecorder()
+ c, _ = gin.CreateTestContext(recorder)
+ c.Request = httptest.NewRequest(http.MethodPost, "/api/admin/accounts/orcarouter", strings.NewReader(body))
+ c.Request.Header.Set("Content-Type", "application/json")
+ handler.AddOrcaRouterAccount(c)
+ if recorder.Code != http.StatusOK {
+ t.Fatalf("AddOrcaRouterAccount status = %d, body=%s", recorder.Code, recorder.Body.String())
+ }
+
+ var added struct {
+ ID int64 `json:"id"`
+ }
+ if err := json.Unmarshal(recorder.Body.Bytes(), &added); err != nil {
+ t.Fatalf("decode add response: %v", err)
+ }
+ if added.ID <= 0 {
+ t.Fatal("add did not return an account ID")
+ }
+
+ row, err := db.GetAccountByID(context.Background(), added.ID)
+ if err != nil {
+ t.Fatalf("GetAccountByID: %v", err)
+ }
+ if got := strings.TrimSpace(row.GetCredential("upstream_type")); got != auth.UpstreamOrcaRouter {
+ t.Fatalf("upstream_type = %q, want orcarouter", got)
+ }
+ if got := strings.TrimSpace(row.Platform); got != "orcarouter" {
+ t.Fatalf("platform = %q, want orcarouter", got)
+ }
+ runtime := handler.store.FindByID(added.ID)
+ if runtime == nil {
+ t.Fatal("runtime account not found after add")
+ }
+ if !runtime.IsOrcaRouterAPI() {
+ t.Fatal("runtime account must be IsOrcaRouterAPI")
+ }
+ if !runtime.IsOpenAIResponsesAPI() {
+ t.Fatal("runtime account must dispatch via IsOpenAIResponsesAPI")
+ }
+}
+
+func TestAddOrcaRouterAccountRejectsMissingModels(t *testing.T) {
+ handler, db := newOrcaRouterTestHandler(t)
+ defer db.Close()
+
+ body := `{"base_url":"https://api.orcarouter.ai/v1","api_key":"sk-orca-test","models":[]}`
+ recorder := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(recorder)
+ c.Request = httptest.NewRequest(http.MethodPost, "/api/admin/accounts/orcarouter", strings.NewReader(body))
+ c.Request.Header.Set("Content-Type", "application/json")
+ handler.AddOrcaRouterAccount(c)
+ if recorder.Code != http.StatusBadRequest {
+ t.Fatalf("expected 400 for empty models, got %d", recorder.Code)
+ }
+}
diff --git a/auth/orcarouter_test.go b/auth/orcarouter_test.go
new file mode 100644
index 000000000..ec68be29e
--- /dev/null
+++ b/auth/orcarouter_test.go
@@ -0,0 +1,64 @@
+package auth
+
+import (
+ "testing"
+)
+
+func TestOrcaRouterAccountPredicates(t *testing.T) {
+ acc := &Account{
+ UpstreamType: UpstreamOrcaRouter,
+ BaseURL: "https://api.orcarouter.ai/v1",
+ APIKey: "sk-orca-test",
+ Models: []string{"orcarouter/auto"},
+ }
+ if !acc.IsOrcaRouterAPI() {
+ t.Fatal("IsOrcaRouterAPI must be true for orcarouter account")
+ }
+ if !acc.IsOpenAIResponsesAPI() {
+ t.Fatal("orcarouter must dispatch through OpenAI Responses machinery (IsOpenAIResponsesAPI)")
+ }
+ if !acc.IsRelayStyle() {
+ t.Fatal("orcarouter must be relay-style")
+ }
+ baseURL, apiKey := acc.OpenAIResponsesCredentials()
+ if baseURL != "https://api.orcarouter.ai/v1" || apiKey != "sk-orca-test" {
+ t.Fatalf("OpenAIResponsesCredentials = (%q, %q)", baseURL, apiKey)
+ }
+}
+
+func TestOrcaRouterAccountMissingCredentialNotRoutable(t *testing.T) {
+ acc := &Account{
+ UpstreamType: UpstreamOrcaRouter,
+ BaseURL: "https://api.orcarouter.ai/v1",
+ APIKey: "",
+ Models: []string{"orcarouter/auto"},
+ }
+ if acc.IsOrcaRouterAPI() {
+ t.Fatal("IsOrcaRouterAPI must require a non-empty API key")
+ }
+ if acc.IsOpenAIResponsesAPI() {
+ t.Fatal("dispatch predicate must require a non-empty API key")
+ }
+}
+
+func TestOrcaRouterUpstreamEndpointBuilding(t *testing.T) {
+ if got := OpenAIResponsesEndpoint("https://api.orcarouter.ai/v1", "/v1/responses"); got != "https://api.orcarouter.ai/v1/responses" {
+ t.Fatalf("endpoint = %q, want https://api.orcarouter.ai/v1/responses", got)
+ }
+ if got := OpenAIResponsesEndpoint("https://api.orcarouter.ai/v1", "/v1/models"); got != "https://api.orcarouter.ai/v1/models" {
+ t.Fatalf("endpoint = %q, want https://api.orcarouter.ai/v1/models", got)
+ }
+ if got := OpenAIResponsesEndpoint("https://api.orcarouter.ai/v1", "/v1/responses/compact"); got != "https://api.orcarouter.ai/v1/responses/compact" {
+ t.Fatalf("endpoint = %q, want compact endpoint", got)
+ }
+}
+
+func TestNormalizeOpenAIResponsesBaseURLPreservesV1(t *testing.T) {
+ got, err := NormalizeOpenAIResponsesBaseURL("https://api.orcarouter.ai/v1/")
+ if err != nil {
+ t.Fatalf("normalize error: %v", err)
+ }
+ if got != "https://api.orcarouter.ai/v1" {
+ t.Fatalf("normalized = %q, want https://api.orcarouter.ai/v1", got)
+ }
+}
diff --git a/auth/store.go b/auth/store.go
index 9e83c3cd4..2fc64c5fa 100644
--- a/auth/store.go
+++ b/auth/store.go
@@ -47,6 +47,12 @@ const (
const UpstreamOpenAIResponses = "openai_responses"
+// UpstreamOrcaRouter marks OrcaRouter gateway accounts (upstream_type credential
+// value). It behaves exactly like openai_responses at dispatch time — OrcaRouter
+// is an OpenAI-Responses compatible gateway — but is surfaced as its own named
+// provider in the admin UI so operators can recognize OrcaRouter-pooled accounts.
+const UpstreamOrcaRouter = "orcarouter"
+
const (
CodexClientMetadataModeAuto = "auto"
CodexClientMetadataModeAlways = "always"
@@ -418,11 +424,39 @@ func (a *Account) isOpenAIResponsesAPILocked() bool {
if a == nil {
return false
}
- return strings.EqualFold(strings.TrimSpace(a.UpstreamType), UpstreamOpenAIResponses) &&
+ return a.isRelayOpenAIResponsesUpstream() &&
strings.TrimSpace(a.BaseURL) != "" &&
strings.TrimSpace(a.APIKey) != ""
}
+// isRelayOpenAIResponsesUpstream 判断上游类型是否走 OpenAI Responses 派发路径:
+// openai_responses(BYO OpenAI 兼容端点)与 orcarouter(OrcaRouter 网关)共用同一套
+// Responses executor / scoped-model / compact 逻辑,仅命名与展示不同。
+func (a *Account) isRelayOpenAIResponsesUpstream() bool {
+ if a == nil {
+ return false
+ }
+ upstream := strings.TrimSpace(a.UpstreamType)
+ return strings.EqualFold(upstream, UpstreamOpenAIResponses) || strings.EqualFold(upstream, UpstreamOrcaRouter)
+}
+
+// isOrcaRouterUpstream 判断该上游类型是否为 OrcaRouter。OrcaRouter 走与
+// openai_responses 完全相同的 Responses 派发路径(见 isRelayOpenAIResponsesUpstream)。
+func (a *Account) isOrcaRouterUpstream() bool {
+ return a != nil && strings.EqualFold(strings.TrimSpace(a.UpstreamType), UpstreamOrcaRouter)
+}
+
+// IsOrcaRouterAPI 报告该账号是否为命名 OrcaRouter 上游(upstream_type=orcarouter)。
+// 用于管理端展示命名 badge 与专用编辑表单;运行时派发与 openai_responses 完全一致。
+func (a *Account) IsOrcaRouterAPI() bool {
+ if a == nil {
+ return false
+ }
+ a.mu.RLock()
+ defer a.mu.RUnlock()
+ return a.isOrcaRouterUpstream() && strings.TrimSpace(a.BaseURL) != "" && strings.TrimSpace(a.APIKey) != ""
+}
+
func (a *Account) hasDispatchCredentialLocked() bool {
if a == nil {
return false
@@ -4697,7 +4731,7 @@ func (s *Store) buildAccountFromRow(ctx context.Context, row *database.AccountRo
modelMapping := strings.TrimSpace(row.GetCredential("model_mapping"))
codexClientMetadataMode := NormalizeCodexClientMetadataMode(row.GetCredential("codex_client_metadata_mode"))
codexFingerprintMode := NormalizeCodexFingerprintMode(row.GetCredential(CodexFingerprintModeCredentialKey))
- isOpenAIResponsesAccount := strings.EqualFold(strings.TrimSpace(upstreamType), UpstreamOpenAIResponses) && strings.TrimSpace(baseURL) != "" && strings.TrimSpace(apiKey) != ""
+ isOpenAIResponsesAccount := (strings.EqualFold(strings.TrimSpace(upstreamType), UpstreamOpenAIResponses) || strings.EqualFold(strings.TrimSpace(upstreamType), UpstreamOrcaRouter)) && strings.TrimSpace(baseURL) != "" && strings.TrimSpace(apiKey) != ""
isGrokAccount := strings.EqualFold(strings.TrimSpace(upstreamType), UpstreamGrok) && (strings.TrimSpace(apiKey) != "" || rt != "" || at != "")
// Agent Identity:无 AT/RT,凭 agent_private_key 动态签名,不能被下面的空凭据 guard 拒绝。
isAgentIdentityAccount := strings.EqualFold(strings.TrimSpace(row.GetCredential("auth_mode")), CodexAuthModeAgentIdentity) &&
@@ -5007,15 +5041,22 @@ const (
dispatchStateReconcileTimeout = 30 * time.Second
)
+// isRelayOpenAIResponsesUpstreamString 报告一个 upstream_type 字符串是否走 OpenAI
+// Responses 派发路径:openai_responses 与 orcarouter 共用同一套 Responses 执行器。
+func isRelayOpenAIResponsesUpstreamString(upstream string) bool {
+ return strings.EqualFold(strings.TrimSpace(upstream), UpstreamOpenAIResponses) ||
+ strings.EqualFold(strings.TrimSpace(upstream), UpstreamOrcaRouter)
+}
+
func openAIResponsesRuntimeConfigDiffers(acc *Account, row *database.AccountRow) bool {
if acc == nil || row == nil ||
- !strings.EqualFold(strings.TrimSpace(row.GetCredential("upstream_type")), UpstreamOpenAIResponses) {
+ !isRelayOpenAIResponsesUpstreamString(strings.TrimSpace(row.GetCredential("upstream_type"))) {
return false
}
acc.mu.RLock()
defer acc.mu.RUnlock()
return acc.CredentialGeneration != row.CredentialGeneration ||
- !strings.EqualFold(strings.TrimSpace(acc.UpstreamType), UpstreamOpenAIResponses) ||
+ !isRelayOpenAIResponsesUpstreamString(strings.TrimSpace(acc.UpstreamType)) ||
strings.TrimRight(strings.TrimSpace(acc.BaseURL), "/") != strings.TrimRight(strings.TrimSpace(row.GetCredential("base_url")), "/") ||
strings.TrimSpace(acc.APIKey) != strings.TrimSpace(row.GetCredential("api_key")) ||
!stringSliceEqual(acc.Models, normalizeModelList(row.GetCredentialStringSlice("models"))) ||
@@ -8031,7 +8072,7 @@ func (s *Store) ApplyOpenAIResponsesConfig(dbID int64, baseURL, apiKey string, m
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if row, err := s.db.GetAccountByID(ctx, dbID); err == nil &&
- strings.EqualFold(strings.TrimSpace(row.GetCredential("upstream_type")), UpstreamOpenAIResponses) {
+ isRelayOpenAIResponsesUpstreamString(strings.TrimSpace(row.GetCredential("upstream_type"))) {
return s.applyOpenAIResponsesConfig(ctx, row, dbID, baseURL, apiKey, models, modelMapping, codexClientMetadataMode, proxyURL)
}
}
@@ -8047,9 +8088,11 @@ func (s *Store) applyOpenAIResponsesConfig(ctx context.Context, row *database.Ac
normalizedBaseURL := strings.TrimRight(strings.TrimSpace(baseURL), "/")
effectiveAPIKey := strings.TrimSpace(apiKey)
credentialGeneration := int64(0)
+ upstreamType := UpstreamOpenAIResponses
loadedPersistedConfig := row != nil &&
- strings.EqualFold(strings.TrimSpace(row.GetCredential("upstream_type")), UpstreamOpenAIResponses)
+ isRelayOpenAIResponsesUpstreamString(strings.TrimSpace(row.GetCredential("upstream_type")))
if loadedPersistedConfig {
+ upstreamType = strings.TrimSpace(row.GetCredential("upstream_type"))
normalizedBaseURL = strings.TrimRight(strings.TrimSpace(row.GetCredential("base_url")), "/")
effectiveAPIKey = strings.TrimSpace(row.GetCredential("api_key"))
models = row.GetCredentialStringSlice("models")
@@ -8057,13 +8100,15 @@ func (s *Store) applyOpenAIResponsesConfig(ctx context.Context, row *database.Ac
codexClientMetadataMode = row.GetCredential("codex_client_metadata_mode")
proxyURL = row.ProxyURL
credentialGeneration = row.CredentialGeneration
+ } else if acc.isOrcaRouterUpstream() {
+ upstreamType = UpstreamOrcaRouter
}
acc.mu.Lock()
identityChanged := normalizedBaseURL != strings.TrimRight(strings.TrimSpace(acc.BaseURL), "/") ||
((loadedPersistedConfig || effectiveAPIKey != "") && effectiveAPIKey != strings.TrimSpace(acc.APIKey)) ||
(credentialGeneration > 0 && credentialGeneration != acc.CredentialGeneration)
- acc.UpstreamType = UpstreamOpenAIResponses
+ acc.UpstreamType = upstreamType
acc.BaseURL = normalizedBaseURL
if loadedPersistedConfig || effectiveAPIKey != "" {
acc.APIKey = effectiveAPIKey
diff --git a/database/postgres.go b/database/postgres.go
index b6182bd74..fc616df17 100644
--- a/database/postgres.go
+++ b/database/postgres.go
@@ -6990,8 +6990,9 @@ func grokIdentityCredentialChanged(before, after map[string]interface{}) bool {
return false
}
-func openAIResponsesIdentityCredentialChanged(before, after map[string]interface{}) bool {
- if !strings.EqualFold(strings.TrimSpace(credentialStringFromMap(after, "upstream_type")), "openai_responses") {
+func responsesIdentityCredentialChanged(before, after map[string]interface{}) bool {
+ upstream := strings.TrimSpace(credentialStringFromMap(after, "upstream_type"))
+ if !strings.EqualFold(upstream, "openai_responses") && !strings.EqualFold(upstream, "orcarouter") {
return false
}
return strings.TrimRight(strings.TrimSpace(credentialStringFromMap(before, "base_url")), "/") !=
@@ -7014,6 +7015,16 @@ func sqliteJSONSetKeySupported(key string) bool {
}
func (db *DB) UpdateOpenAIResponsesAccount(ctx context.Context, id int64, name string, credentials map[string]interface{}, proxyURL string) error {
+ return db.updateResponsesAccount(ctx, id, name, credentials, proxyURL, "openai")
+}
+
+// UpdateOrcaRouterAccount 更新一个 OrcaRouter 网关账号;运行时与 openai_responses
+// 完全一致,仅平台列标为 orcarouter。
+func (db *DB) UpdateOrcaRouterAccount(ctx context.Context, id int64, name string, credentials map[string]interface{}, proxyURL string) error {
+ return db.updateResponsesAccount(ctx, id, name, credentials, proxyURL, "orcarouter")
+}
+
+func (db *DB) updateResponsesAccount(ctx context.Context, id int64, name string, credentials map[string]interface{}, proxyURL, platform string) error {
tx, err := db.conn.BeginTx(ctx, nil)
if err != nil {
return err
@@ -7032,7 +7043,7 @@ func (db *DB) UpdateOpenAIResponsesAccount(ctx context.Context, id int64, name s
current := decodeCredentials(currentRaw)
merged := mergeCredentialMaps(cloneCredentialUpdates(current), credentials)
- identityChanged := openAIResponsesIdentityCredentialChanged(current, merged)
+ identityChanged := responsesIdentityCredentialChanged(current, merged)
credJSON, err := json.Marshal(merged)
if err != nil {
return fmt.Errorf("序列化 credentials 失败: %w", err)
@@ -7042,9 +7053,9 @@ func (db *DB) UpdateOpenAIResponsesAccount(ctx context.Context, id int64, name s
if identityChanged {
identityUpdate = ", credential_generation = credential_generation + 1, status = 'active', error_message = '', cooldown_reason = '', cooldown_until = NULL"
}
- updateQuery := `UPDATE accounts SET name = $1, credentials = $2, proxy_url = $3, platform = 'openai', type = 'responses_api'` + identityUpdate + `, updated_at = CURRENT_TIMESTAMP WHERE id = $4`
+ updateQuery := `UPDATE accounts SET name = $1, credentials = $2, proxy_url = $3, platform = '` + platform + `', type = 'responses_api'` + identityUpdate + `, updated_at = CURRENT_TIMESTAMP WHERE id = $4`
if !db.isSQLite() {
- updateQuery = `UPDATE accounts SET name = $1, credentials = $2::jsonb, proxy_url = $3, platform = 'openai', type = 'responses_api'` + identityUpdate + `, updated_at = CURRENT_TIMESTAMP WHERE id = $4`
+ updateQuery = `UPDATE accounts SET name = $1, credentials = $2::jsonb, proxy_url = $3, platform = '` + platform + `', type = 'responses_api'` + identityUpdate + `, updated_at = CURRENT_TIMESTAMP WHERE id = $4`
}
res, err := tx.ExecContext(ctx, updateQuery, name, credJSON, proxyURL, id)
if err != nil {
@@ -7585,6 +7596,26 @@ func (db *DB) InsertOpenAIResponsesAccount(ctx context.Context, name string, cre
)
}
+// InsertOrcaRouterAccount 插入一个 OrcaRouter 网关账号(upstream_type=orcarouter)。
+// OrcaRouter 是 OpenAI-Responses 兼容网关,运行时与 openai_responses 走同一套派发,
+// 这里用独立的 platform='orcarouter' 便于管理端识别命名 provider。
+func (db *DB) InsertOrcaRouterAccount(ctx context.Context, name string, credentials map[string]interface{}, proxyURL string) (int64, error) {
+ if credentials == nil {
+ credentials = map[string]interface{}{}
+ }
+ credJSON, err := json.Marshal(credentials)
+ if err != nil {
+ return 0, err
+ }
+
+ return db.insertAccountRowWithFamily(ctx,
+ `INSERT INTO accounts (name, platform, type, credentials, proxy_url) VALUES ($1, 'orcarouter', 'responses_api', $2, $3) RETURNING id`,
+ `INSERT INTO accounts (name, platform, type, credentials, proxy_url) VALUES ($1, 'orcarouter', 'responses_api', $2, $3)`,
+ credentials,
+ name, credJSON, proxyURL,
+ )
+}
+
// InsertAccountWithUpstream 插入一个指定 platform / type 的账号(用于 Grok 等
// 非 Codex 上游),credentials 全量入库。
func (db *DB) InsertAccountWithUpstream(ctx context.Context, name, platform, accountType string, credentials map[string]interface{}, proxyURL string) (int64, error) {
@@ -7841,7 +7872,7 @@ func (db *DB) GetAllOpenAIAPIKeys(ctx context.Context) (map[string]bool, error)
}
apiKey := strings.TrimSpace(credentialString(raw, "api_key"))
upstreamType := strings.TrimSpace(credentialString(raw, "upstream_type"))
- if apiKey != "" && upstreamType == "openai_responses" {
+ if apiKey != "" && (upstreamType == "openai_responses" || upstreamType == "orcarouter") {
result[apiKey] = true
}
}
diff --git a/frontend/src/api.ts b/frontend/src/api.ts
index 01c042729..dc378e556 100644
--- a/frontend/src/api.ts
+++ b/frontend/src/api.ts
@@ -590,6 +590,12 @@ export const api = {
request('/accounts/openai-responses/models', { method: 'POST', body: JSON.stringify(data) }),
updateOpenAIResponsesAccount: (id: number, data: UpdateOpenAIResponsesAccountRequest) =>
request(`/accounts/${id}/openai-responses`, { method: 'PATCH', body: JSON.stringify(data) }),
+ addOrcaRouterAccount: (data: AddOpenAIResponsesAccountRequest) =>
+ request('/accounts/orcarouter', { method: 'POST', body: JSON.stringify(data) }),
+ fetchOrcaRouterModels: (data: FetchOpenAIResponsesModelsRequest) =>
+ request('/accounts/orcarouter/models', { method: 'POST', body: JSON.stringify(data) }),
+ updateOrcaRouterAccount: (id: number, data: UpdateOpenAIResponsesAccountRequest) =>
+ request(`/accounts/${id}/orcarouter`, { method: 'PATCH', body: JSON.stringify(data) }),
addGrokAccount: (data: AddGrokAccountRequest) =>
request('/accounts/grok', { method: 'POST', body: JSON.stringify(data) }),
fetchGrokModels: (data: AddGrokAccountRequest) =>
diff --git a/frontend/src/assets/providers/orcarouter.png b/frontend/src/assets/providers/orcarouter.png
new file mode 100644
index 000000000..a43be9606
Binary files /dev/null and b/frontend/src/assets/providers/orcarouter.png differ
diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json
index 783489867..4c033778d 100644
--- a/frontend/src/locales/en.json
+++ b/frontend/src/locales/en.json
@@ -1125,8 +1125,11 @@
"addMethodAT": "Access Token",
"addMethodSession": "ChatGPT Session",
"addMethodOpenAI": "API Key",
+ "addMethodOrcaRouter": "OrcaRouter",
"addMethodOAuth": "OAuth",
"addMethodAgentIdentity": "Agent Identity",
+ "orcaRouterResponsesTitle": "OrcaRouter Gateway",
+ "orcaRouterResponsesDesc": "Use Base URL + API Key to call the OrcaRouter gateway (OpenAI Responses-compatible). Route codex traffic through OrcaRouter with a sk-orca- key.",
"agentIdentityHint": "Import a Codex Agent Identity auth.json. No OAuth access/refresh token is stored — each upstream request is dynamically signed with the agent private key.",
"agentIdentityJsonLabel": "Agent Identity auth.json",
"agentIdentityJsonPlaceholder": "Paste the full auth.json (must contain agent_identity with agent_runtime_id / agent_private_key, or auth_mode=agentIdentity)",
diff --git a/frontend/src/locales/zh.json b/frontend/src/locales/zh.json
index e803db6ec..a78da62e0 100644
--- a/frontend/src/locales/zh.json
+++ b/frontend/src/locales/zh.json
@@ -1125,8 +1125,11 @@
"addMethodAT": "Access Token",
"addMethodSession": "ChatGPT Session",
"addMethodOpenAI": "API Key",
+ "addMethodOrcaRouter": "OrcaRouter",
"addMethodOAuth": "OAuth 授权",
"addMethodAgentIdentity": "Agent Identity",
+ "orcaRouterResponsesTitle": "OrcaRouter 网关",
+ "orcaRouterResponsesDesc": "使用 Base URL + API Key 直连 OrcaRouter 网关(OpenAI Responses 兼容)。用 sk-orca- 密钥把 Codex 流量经 OrcaRouter 路由。",
"agentIdentityHint": "导入 Codex Agent Identity auth.json,不保存 OAuth access token 或 refresh token;每次上游请求都会用 agent 私钥动态签名。",
"agentIdentityJsonLabel": "Agent Identity auth.json",
"agentIdentityJsonPlaceholder": "粘贴完整 auth.json(须含 agent_identity 且带 agent_runtime_id / agent_private_key,或 auth_mode=agentIdentity)",
diff --git a/frontend/src/pages/Accounts.tsx b/frontend/src/pages/Accounts.tsx
index 0cb1f7db6..f1f5e2db8 100644
--- a/frontend/src/pages/Accounts.tsx
+++ b/frontend/src/pages/Accounts.tsx
@@ -27,6 +27,7 @@ import {
usePersistedPageSize,
} from "../hooks/usePersistedPageSize";
import { useToast } from "../hooks/useToast";
+import orcaRouterLogo from "../assets/providers/orcarouter.png";
import type {
AccountRow,
AccountHealthBucket,
@@ -466,9 +467,9 @@ function parseModelTokens(value: string): string[] {
});
}
-/** Codex 官方 OAuth/AT 账号(非 OpenAI Responses 中转、非 Grok),即走 Codex 出站路径的账号。 */
+/** Codex 官方 OAuth/AT 账号(非 OpenAI Responses 中转、非 OrcaRouter、非 Grok),即走 Codex 出站路径的账号。 */
function isCodexOfficialAccount(account: AccountRow): boolean {
- return !account.openai_responses_api && !account.grok_api;
+ return !account.openai_responses_api && !account.orcarouter_api && !account.grok_api;
}
function codexFingerprintModeOptions(
@@ -647,7 +648,7 @@ function mergeModelLists(current: string[], incoming: string[]): string[] {
}
function formatAccountName(account: AccountRow): string {
- if (account.openai_responses_api || account.grok_api) {
+ if (account.openai_responses_api || account.orcarouter_api || account.grok_api) {
return account.name?.trim() || `ID ${account.id}`;
}
return account.email || account.name || `ID ${account.id}`;
@@ -1059,6 +1060,13 @@ const AccountTableRow = memo(function AccountTableRow({
{account.openai_responses_api ? (
+ ) : account.orcarouter_api ? (
+
) : (
)}
@@ -1073,7 +1081,7 @@ const AccountTableRow = memo(function AccountTableRow({
actions.openDetail(account);
}}
>
- {account.openai_responses_api || account.grok_api
+ {account.openai_responses_api || account.orcarouter_api || account.grok_api
? formatAccountName(account)
: formatAccountListEmail(account)}
@@ -1106,6 +1114,7 @@ const AccountTableRow = memo(function AccountTableRow({
)}
{(account.at_only ||
account.openai_responses_api ||
+ account.orcarouter_api ||
account.grok_api ||
account.agent_identity ||
account.locked ||
@@ -1130,6 +1139,11 @@ const AccountTableRow = memo(function AccountTableRow({
Responses API
)}
+ {account.orcarouter_api && (
+
+ OrcaRouter
+
+ )}
{account.grok_api && (
@@ -1770,7 +1784,7 @@ export default function Accounts() {
done: false,
});
const [addMethod, setAddMethod] = useState<
- "rt" | "st" | "at" | "session" | "openai" | "oauth" | "agentIdentity"
+ "rt" | "st" | "at" | "session" | "openai" | "orcarouter" | "oauth" | "agentIdentity"
>("oauth");
const [agentIdentityJson, setAgentIdentityJson] = useState("");
const [agentIdentityProxyUrl, setAgentIdentityProxyUrl] = useState("");
@@ -3232,7 +3246,11 @@ export default function Accounts() {
if (!openAIForm.api_key.trim()) return;
setOpenAIModelsLoading(true);
try {
- const result = await api.fetchOpenAIResponsesModels({
+ const fetchModels =
+ addMethod === "orcarouter"
+ ? api.fetchOrcaRouterModels
+ : api.fetchOpenAIResponsesModels;
+ const result = await fetchModels({
base_url: openAIForm.base_url,
api_key: openAIForm.api_key,
proxy_url: openAIForm.proxy_url,
@@ -3373,16 +3391,24 @@ export default function Accounts() {
}
setSubmitting(true);
try {
- await api.addOpenAIResponsesAccount({
+ const payload = {
...openAIForm,
models,
model_mapping: parsedModelMapping.value,
custom_headers: parsedCustomHeaders.value,
- });
+ };
+ if (addMethod === "orcarouter") {
+ await api.addOrcaRouterAccount(payload);
+ } else {
+ await api.addOpenAIResponsesAccount(payload);
+ }
showToast(t("accounts.addSuccess"));
setShowAdd(false);
setOpenAIForm({
- base_url: "https://api.openai.com",
+ base_url:
+ addMethod === "orcarouter"
+ ? "https://api.orcarouter.ai/v1"
+ : "https://api.openai.com",
api_key: "",
models: [],
codex_client_metadata_mode: "auto",
@@ -3405,10 +3431,13 @@ export default function Accounts() {
};
const handleFetchEditOpenAIModels = async () => {
- if (!editingAccount?.openai_responses_api) return;
+ if (!editingAccount?.openai_responses_api && !editingAccount?.orcarouter_api) return;
setEditOpenAIModelsLoading(true);
try {
- const result = await api.fetchOpenAIResponsesModels({
+ const fetchModels = editingAccount?.orcarouter_api
+ ? api.fetchOrcaRouterModels
+ : api.fetchOpenAIResponsesModels;
+ const result = await fetchModels({
account_id: editingAccount.id,
base_url: editOpenAIForm.base_url,
api_key: editOpenAIForm.api_key ?? "",
@@ -3436,7 +3465,7 @@ export default function Accounts() {
};
const handleSaveOpenAIAccountSettings = async () => {
- if (!editingAccount?.openai_responses_api) return;
+ if (!editingAccount?.openai_responses_api && !editingAccount?.orcarouter_api) return;
if (!editOpenAIForm.base_url.trim() || editOpenAIForm.models.length === 0) {
showToast(t("accounts.openaiAccountInvalid"), "error");
return;
@@ -3457,7 +3486,10 @@ export default function Accounts() {
}
setEditSubmitting(true);
try {
- await api.updateOpenAIResponsesAccount(editingAccount.id, {
+ const updateAccount = editingAccount?.orcarouter_api
+ ? api.updateOrcaRouterAccount
+ : api.updateOpenAIResponsesAccount;
+ await updateAccount(editingAccount.id, {
...editOpenAIForm,
api_key: editOpenAIForm.api_key?.trim() || undefined,
model_mapping: parsedModelMapping.value,
@@ -5313,7 +5345,7 @@ export default function Accounts() {
batchAutoPause7dThresholdInput,
);
const openAIAccountInputInvalid = Boolean(
- editingAccount?.openai_responses_api &&
+ (editingAccount?.openai_responses_api || editingAccount?.orcarouter_api) &&
editTab === "account" &&
(!editOpenAIForm.base_url.trim() || editOpenAIForm.models.length === 0),
);
@@ -5426,7 +5458,7 @@ export default function Accounts() {
};
const handleSaveAccountEditor = async () => {
- if (editingAccount?.openai_responses_api && editTab === "account") {
+ if ((editingAccount?.openai_responses_api || editingAccount?.orcarouter_api) && editTab === "account") {
await handleSaveOpenAIAccountSettings();
return;
}
@@ -7193,7 +7225,7 @@ export default function Accounts() {
>
{submitting ? t("accounts.adding") : t("accounts.submit")}
- ) : addMethod === "openai" ? (
+ ) : addMethod === "openai" || addMethod === "orcarouter" ? (
void handleAddOpenAIResponses()}
disabled={
@@ -7308,6 +7340,23 @@ export default function Accounts() {
{t("accounts.addMethodOpenAI")}
+ {
+ setAddMethod("orcarouter");
+ setOpenAIForm((form) => ({
+ ...form,
+ base_url: "https://api.orcarouter.ai/v1",
+ }));
+ }}
+ className={`min-w-0 flex-1 flex items-center justify-center gap-1.5 rounded-lg px-2 py-2 text-sm font-semibold whitespace-nowrap transition-all ${
+ addMethod === "orcarouter"
+ ? "bg-background shadow-sm text-foreground"
+ : "text-muted-foreground hover:text-foreground"
+ }`}
+ >
+
+ {t("accounts.addMethodOrcaRouter")}
+
setAddMethod("agentIdentity")}
className={`min-w-0 flex-1 flex items-center justify-center gap-1.5 rounded-lg px-2 py-2 text-sm font-semibold whitespace-nowrap transition-all ${
@@ -7457,13 +7506,19 @@ export default function Accounts() {
onChange: setAddCustomHeadersText,
})}
- ) : addMethod === "openai" ? (
+ ) : addMethod === "openai" || addMethod === "orcarouter" ? (
- {t("accounts.openaiResponsesTitle")}
+ {addMethod === "orcarouter"
+ ? t("accounts.orcaRouterResponsesTitle")
+ : t("accounts.openaiResponsesTitle")}
+
+
+ {addMethod === "orcarouter"
+ ? t("accounts.orcaRouterResponsesDesc")
+ : t("accounts.openaiResponsesDesc")}
-
{t("accounts.openaiResponsesDesc")}
@@ -8508,6 +8563,7 @@ export default function Accounts() {
{/* 选项卡切换 */}
{(editingAccount.openai_responses_api ||
+ editingAccount.orcarouter_api ||
isOAuthAccount(editingAccount)) && (
- OpenAI Responses API 参数
+
+ {editingAccount.orcarouter_api
+ ? "OrcaRouter 网关参数"
+ : "OpenAI Responses API 参数"}
+
@@ -10488,7 +10548,7 @@ function RecycleBinView({
}, [load]);
const stats = useMemo(() => {
- const relay = rows.filter((row) => row.openai_responses_api).length;
+ const relay = rows.filter((row) => row.openai_responses_api || row.orcarouter_api).length;
const dayAgo = Date.now() - 24 * 60 * 60 * 1000;
const recent24h = rows.filter((row) => {
if (!row.deleted_at) return false;
@@ -11087,11 +11147,13 @@ function RecycleBinView({
{row.email || row.name || `ID ${row.id}`}
- {row.openai_responses_api && row.base_url ? (
-
- {row.base_url}
-
- ) : null}
+ {row.openai_responses_api || row.orcarouter_api
+ ? (row.base_url && (
+
+ {row.base_url}
+
+ ))
+ : null}
@@ -11102,7 +11164,7 @@ function RecycleBinView({
- {row.openai_responses_api
+ {row.openai_responses_api || row.orcarouter_api
? t("accounts.recycleBinTypeRelay")
: t("accounts.recycleBinTypeOauth")}
@@ -11299,6 +11361,7 @@ function recycleBinRowToAccountRow(row: RecycleBinAccountRow): AccountRow {
plan_type: row.plan_type,
status: "deleted",
openai_responses_api: row.openai_responses_api,
+ orcarouter_api: row.orcarouter_api,
base_url: row.base_url,
models: row.models,
proxy_url: "",
@@ -12676,11 +12739,12 @@ function AccountRowActionsMenu({
onDelete: () => void;
}) {
const refreshDisabled =
- refreshing || account.at_only || account.openai_responses_api;
+ refreshing || account.at_only || account.openai_responses_api || account.orcarouter_api;
const authJsonDisabled =
authJsonExporting ||
account.at_only ||
account.openai_responses_api ||
+ account.orcarouter_api ||
account.grok_api ||
account.agent_identity;
const resetCredits = account.rate_limit_reset_credits ?? 0;
@@ -12706,7 +12770,7 @@ function AccountRowActionsMenu({
),
disabled: refreshDisabled,
title:
- account.at_only || account.openai_responses_api
+ account.at_only || account.openai_responses_api || account.orcarouter_api
? t("accounts.atRefreshDisabled")
: undefined,
onSelect: onRefresh,
@@ -12719,6 +12783,7 @@ function AccountRowActionsMenu({
title:
account.at_only ||
account.openai_responses_api ||
+ account.orcarouter_api ||
account.grok_api ||
account.agent_identity
? t("accounts.authJsonDisabled")
@@ -12768,7 +12833,7 @@ function AccountRowActionsMenu({
onSelect: onResetCredits,
},
// 支持模型白名单仅适用于 OAuth(ChatGPT)账号,relay/Grok 账号不显示。
- ...(onEditModels && !account.openai_responses_api && !account.grok_api
+ ...(onEditModels && !account.openai_responses_api && !account.orcarouter_api && !account.grok_api
? [
{
key: "edit-models",
@@ -13092,7 +13157,7 @@ function AccountMobileCard({
// 成本列的官方胶囊点击后跳到用量弹窗的官方统计 tab。
onOpenOfficialUsage?: () => void;
}) {
- const displayName = account.openai_responses_api
+ const displayName = account.openai_responses_api || account.orcarouter_api
? formatAccountName(account)
: formatAccountListEmail(account);
const fullName = formatAccountName(account);
@@ -13105,6 +13170,7 @@ function AccountMobileCard({
const hasStateBadges =
account.at_only ||
account.openai_responses_api ||
+ account.orcarouter_api ||
account.grok_api ||
account.locked;
const modelCooldownCount = account.model_cooldowns?.length ?? 0;
@@ -13268,6 +13334,11 @@ function AccountMobileCard({
Responses API
)}
+ {account.orcarouter_api && (
+
+ OrcaRouter
+
+ )}
{account.grok_api && (
@@ -13550,6 +13621,11 @@ function AccountMobileCard({
Responses API
)}
+ {account.orcarouter_api && (
+
+ OrcaRouter
+
+ )}
{account.grok_api && (
@@ -13988,10 +14064,10 @@ function TestConnectionModal({
onSettledRef.current();
}, []);
- // Grok 与 openai_responses 同属"账号自带模型清单"的 relay 风格账号,
+ // Grok 与 openai_responses/orcarouter 同属"账号自带模型清单"的 relay 风格账号,
// 测试模型选择逻辑一致(用 account.models 而非上游 /v1/models 全量)。
const isOpenAIResponsesAccount = Boolean(
- account.openai_responses_api || account.grok_api,
+ account.openai_responses_api || account.orcarouter_api || account.grok_api,
);
const modelSelectOptions = useMemo(
diff --git a/frontend/src/types.ts b/frontend/src/types.ts
index 72b94d103..a0e27bb8c 100644
--- a/frontend/src/types.ts
+++ b/frontend/src/types.ts
@@ -102,6 +102,7 @@ export interface AccountRow {
access_token_type?: string
account_type?: string
openai_responses_api?: boolean
+ orcarouter_api?: boolean
grok_api?: boolean
agent_identity?: boolean
grok_auth_kind?: string
@@ -538,6 +539,7 @@ export interface RecycleBinAccountRow {
at_only?: boolean
access_token_type?: string
openai_responses_api?: boolean
+ orcarouter_api?: boolean
base_url?: string
models?: string[]
created_at: ISODateString