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 + @@ -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 ? ( + OrcaRouter ) : ( )} @@ -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" ? ( +
One compatible gatewayOpenAI-style Chat Completions / Responses / Images, Anthropic Messages, prefixless compatibility routes, and native Codex Responses forwarding are all exposed through one service.
Named upstream providersBeyond 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 schedulerSelection 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 consoleThe 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 shapesUse 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.