Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -41,4 +41,5 @@ skills-lock.json
Operation.md
/home
.tools

AGENTS.md
CLAUDE.md
36 changes: 36 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,3 +144,39 @@ This includes investigation notes, review outputs, scratch plans, working drafts
Do NOT place these files in the repository root, `docs/`, `.github/`, or source directories unless the content is explicitly intended to be public project documentation. `.tmp/` is ignored by Git; use it to prevent accidental publication of sensitive or internal process information to the open-source GitHub repository. If process documents are found outside `.tmp/`, move them into `.tmp/` before committing.

Local agent workflow files, including `.agents/skills/` and `.agents/upstream-sync/`, are private project-maintenance material for this workspace and MUST NOT be committed to the public GitHub repository. Keep reusable private agent instructions there locally, or move public-safe documentation into normal project docs when it is intentionally meant to be open source.

### Rule 9: Monitor Independent New API Upstream Modules and Plugins

New API-derived capabilities that evolve outside MAX-API's normal dependency graph MUST be checked explicitly. This includes independent Go modules such as `relaykit`, bundled Task Plugins under `plugins/`, and their host-side plugin or protocol-conversion frameworks.

Run the private upstream component check before any of the following work:

- changing relay DTOs, protocol conversion, streaming conversion, usage normalization, or finish-reason mapping;
- changing Task Plugin loading, routing, sandboxing, artifact handling, usage facts, or plugin billing integration;
- preparing a release, performing an upstream feature/security comparison, or claiming parity with the latest New API;
- adopting or upgrading an independent New API module or plugin.

Required procedure:

1. Read `.agents/upstream-sync/components.json`, `.agents/upstream-sync/new-api-sync.md`, and the living implementation plan at `new-api/.tmp/MAX_API_UPSTREAM_IMPLEMENTATION_PLAN.md`.
2. Run the checker from the repository root with the cross-platform `pwsh` executable when available:
`pwsh -NoProfile -ExecutionPolicy Bypass -File .agents/upstream-sync/check-new-api.ps1`.
On Windows hosts without `pwsh`, use the equivalent Windows PowerShell fallback:
`powershell -NoProfile -ExecutionPolicy Bypass -File .agents/upstream-sync/check-new-api.ps1`.
3. Treat the ignored `new-api/new-api` source copy as reference material only. Its presence does not prove that it matches upstream HEAD.
4. If the remote state cannot be verified, report `需要进一步验证`; do not describe the local copy as current.
5. Detection is not acceptance: never automatically merge or wholesale copy an upstream change. Classify it as `pending`, `accepted`, `ported`, `skipped`, `superseded`, or `blocked`, with rationale.
6. Advance the reviewed baseline only after the affected code and compatibility gates have been inspected and the relevant tests have passed. Merely detecting a newer commit MUST NOT advance the baseline.
7. Keep generated comparison reports under `.tmp/`. Keep the reusable checker, component registry, and local sync state under `.agents/upstream-sync/`; these remain private and uncommitted under Rule 8.
8. Before implementing a listed capability, update the living plan with the current MAX/upstream baselines, revalidated evidence, scope, dependency, license decision, risks, and test plan; mark the item `实施中`.
9. After each related iteration, update the same plan with actual files and key logic, test results, residual verification gaps, upstream component status, delivery status, change log, and the next iteration entry. A code change is not complete until this plan update is performed.

RelayKit-specific requirements:

- Describe RelayKit accurately as an independent, full-matrix, auditable protocol-conversion module. MAX-API already has multiple conversion paths and `BillingUsage`; do not claim that MAX-API has no protocol conversion.
- Review request, non-stream response, and streaming response conversion separately, including finalization behavior.
- Verify explicit zero-value preservation, tool calls, multimodal input, reasoning content, finish reasons, and usage mapping.
- Review changes to converter identity, quality, steps, usage, and diagnostics as compatibility-sensitive interface changes.
- RelayKit does not own billing. Preserve MAX-API's billing, reservation, settlement, retry, and logging invariants, and prove that normalized usage is applied exactly once.
- If the private checker or registry is unavailable in a fresh workspace, perform the equivalent remote commit/path comparison manually and record the missing maintenance prerequisite instead of silently skipping the check.
- If the living plan is missing in a fresh workspace, recreate it under `new-api/.tmp/` from the current code and upstream records before continuing; never substitute an old chat summary for the plan.
7 changes: 6 additions & 1 deletion controller/relay.go
Original file line number Diff line number Diff line change
Expand Up @@ -683,7 +683,7 @@ func RelayTask(c *gin.Context) {
}
c.Request.Body = io.NopCloser(bodyStorage)

result, taskErr = relay.RelayTaskSubmit(c, relayInfo)
result, taskErr = relayTaskSubmitAttempt(c, relayInfo, retryParam.GetRetry())
if taskErr == nil {
break
}
Expand Down Expand Up @@ -816,6 +816,11 @@ func respondTaskError(c *gin.Context, taskErr *dto.TaskError) {
c.JSON(taskErr.StatusCode, taskErr)
}

func relayTaskSubmitAttempt(c *gin.Context, relayInfo *relaycommon.RelayInfo, retryIndex int) (*relay.TaskSubmitResult, *dto.TaskError) {
relayInfo.RetryIndex = retryIndex
return relay.RelayTaskSubmit(c, relayInfo)
}

func shouldRetryTaskRelay(c *gin.Context, channelId int, taskErr *dto.TaskError, retryTimes int) bool {
if taskErr == nil {
return false
Expand Down
18 changes: 18 additions & 0 deletions controller/relay_retry_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,31 @@ import (

"github.com/MAX-API-Next/MAX-API/common"
"github.com/MAX-API-Next/MAX-API/dto"
relaycommon "github.com/MAX-API-Next/MAX-API/relay/common"
"github.com/MAX-API-Next/MAX-API/setting/operation_setting"
"github.com/MAX-API-Next/MAX-API/types"

"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)

func TestRelayTaskSubmitAttemptAdvancesToolUsageRetryIndex(t *testing.T) {
gin.SetMode(gin.TestMode)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Set("platform", "invalid-test-platform")
info := &relaycommon.RelayInfo{
OriginModelName: "task-test",
RetryIndex: 0,
ToolUsage: relaycommon.NewToolUsageLedger("task-test"),
}
info.ToolUsage.BeginAttempt(0)

_, taskErr := relayTaskSubmitAttempt(c, info, 1)
require.NotNil(t, taskErr)
require.Equal(t, 1, info.RetryIndex)
require.Equal(t, 1, info.ToolUsageSnapshot().Attempt)
}

func TestShouldRetryEmptyResponseRequiresFeatureFlag(t *testing.T) {
gin.SetMode(gin.TestMode)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
Expand Down
72 changes: 49 additions & 23 deletions controller/subscription.go
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,37 @@ type AdminUpsertSubscriptionPlanRequest struct {
Plan model.SubscriptionPlan `json:"plan"`
}

func subscriptionPlanUpdateMap(plan model.SubscriptionPlan) map[string]interface{} {
updates := map[string]interface{}{
"title": plan.Title,
"subtitle": plan.Subtitle,
"price_amount": plan.PriceAmount,
"currency": plan.Currency,
"duration_unit": plan.DurationUnit,
"duration_value": plan.DurationValue,
"custom_seconds": plan.CustomSeconds,
"enabled": plan.Enabled,
"sort_order": plan.SortOrder,
"stripe_price_id": plan.StripePriceId,
"creem_product_id": plan.CreemProductId,
"waffo_pancake_product_id": plan.WaffoPancakeProductId,
"max_purchase_per_user": plan.MaxPurchasePerUser,
"total_amount": plan.TotalAmount,
"upgrade_group": plan.UpgradeGroup,
"downgrade_group": plan.DowngradeGroup,
"quota_reset_period": plan.QuotaResetPeriod,
"quota_reset_custom_seconds": plan.QuotaResetCustomSeconds,
"updated_at": common.GetTimestamp(),
}
if plan.AllowBalancePay != nil {
updates["allow_balance_pay"] = *plan.AllowBalancePay
}
if plan.AllowWalletOverflow != nil {
updates["allow_wallet_overflow"] = *plan.AllowWalletOverflow
}
return updates
}

func AdminCreateSubscriptionPlan(c *gin.Context) {
if !requirePaymentCompliance(c) {
return
Expand Down Expand Up @@ -168,6 +199,9 @@ func AdminCreateSubscriptionPlan(c *gin.Context) {
if req.Plan.AllowBalancePay == nil {
req.Plan.AllowBalancePay = common.GetPointer(true)
}
if req.Plan.AllowWalletOverflow == nil {
req.Plan.AllowWalletOverflow = common.GetPointer(true)
}
if req.Plan.DurationUnit == "" {
req.Plan.DurationUnit = model.SubscriptionDurationMonth
}
Expand All @@ -189,6 +223,13 @@ func AdminCreateSubscriptionPlan(c *gin.Context) {
return
}
}
req.Plan.DowngradeGroup = strings.TrimSpace(req.Plan.DowngradeGroup)
if req.Plan.DowngradeGroup != "" {
if _, ok := ratio_setting.GetGroupRatioCopy()[req.Plan.DowngradeGroup]; !ok {
common.ApiErrorMsg(c, "降级分组不存在")
return
}
}
req.Plan.QuotaResetPeriod = model.NormalizeResetPeriod(req.Plan.QuotaResetPeriod)
if req.Plan.QuotaResetPeriod == model.SubscriptionResetCustom && req.Plan.QuotaResetCustomSeconds <= 0 {
common.ApiErrorMsg(c, "自定义重置周期需大于0秒")
Expand Down Expand Up @@ -256,6 +297,13 @@ func AdminUpdateSubscriptionPlan(c *gin.Context) {
return
}
}
req.Plan.DowngradeGroup = strings.TrimSpace(req.Plan.DowngradeGroup)
if req.Plan.DowngradeGroup != "" {
if _, ok := ratio_setting.GetGroupRatioCopy()[req.Plan.DowngradeGroup]; !ok {
common.ApiErrorMsg(c, "降级分组不存在")
return
}
}
req.Plan.QuotaResetPeriod = model.NormalizeResetPeriod(req.Plan.QuotaResetPeriod)
if req.Plan.QuotaResetPeriod == model.SubscriptionResetCustom && req.Plan.QuotaResetCustomSeconds <= 0 {
common.ApiErrorMsg(c, "自定义重置周期需大于0秒")
Expand All @@ -264,29 +312,7 @@ func AdminUpdateSubscriptionPlan(c *gin.Context) {

err := model.DB.Transaction(func(tx *gorm.DB) error {
// update plan (allow zero values updates with map)
updateMap := map[string]interface{}{
"title": req.Plan.Title,
"subtitle": req.Plan.Subtitle,
"price_amount": req.Plan.PriceAmount,
"currency": req.Plan.Currency,
"duration_unit": req.Plan.DurationUnit,
"duration_value": req.Plan.DurationValue,
"custom_seconds": req.Plan.CustomSeconds,
"enabled": req.Plan.Enabled,
"sort_order": req.Plan.SortOrder,
"stripe_price_id": req.Plan.StripePriceId,
"creem_product_id": req.Plan.CreemProductId,
"waffo_pancake_product_id": req.Plan.WaffoPancakeProductId,
"max_purchase_per_user": req.Plan.MaxPurchasePerUser,
"total_amount": req.Plan.TotalAmount,
"upgrade_group": req.Plan.UpgradeGroup,
"quota_reset_period": req.Plan.QuotaResetPeriod,
"quota_reset_custom_seconds": req.Plan.QuotaResetCustomSeconds,
"updated_at": common.GetTimestamp(),
}
if req.Plan.AllowBalancePay != nil {
updateMap["allow_balance_pay"] = *req.Plan.AllowBalancePay
}
updateMap := subscriptionPlanUpdateMap(req.Plan)
if err := tx.Model(&model.SubscriptionPlan{}).Where("id = ?", id).Updates(updateMap).Error; err != nil {
return err
}
Expand Down
25 changes: 25 additions & 0 deletions controller/subscription_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,16 @@ import (
"bytes"
"net/http"
"net/http/httptest"
"sync"
"testing"

"github.com/MAX-API-Next/MAX-API/common"
appi18n "github.com/MAX-API-Next/MAX-API/i18n"
"github.com/MAX-API-Next/MAX-API/model"
"github.com/MAX-API-Next/MAX-API/setting/operation_setting"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
"gorm.io/gorm/schema"
)

type subscriptionAPIResponse struct {
Expand Down Expand Up @@ -82,3 +85,25 @@ func TestAdminResetPlanSubscriptionsRequiresPaymentCompliance(t *testing.T) {
require.False(t, response.Success)
require.NotEmpty(t, response.Message)
}

func TestSubscriptionPlanUpdateMapUsesOnlyModelColumns(t *testing.T) {
parsed, err := schema.Parse(&model.SubscriptionPlan{}, &sync.Map{}, schema.NamingStrategy{})
require.NoError(t, err)
columns := make(map[string]struct{}, len(parsed.DBNames))
for _, name := range parsed.DBNames {
columns[name] = struct{}{}
}
allowBalance := false
allowWallet := false
updates := subscriptionPlanUpdateMap(model.SubscriptionPlan{
Title: "plan", AllowBalancePay: &allowBalance, AllowWalletOverflow: &allowWallet,
DowngradeGroup: "default",
})
for key := range updates {
if _, ok := columns[key]; !ok {
t.Fatalf("subscription plan update key %q is not a model column", key)
}
}
require.Contains(t, updates, "allow_wallet_overflow")
require.Contains(t, updates, "downgrade_group")
}
2 changes: 2 additions & 0 deletions dto/gemini.go
Original file line number Diff line number Diff line change
Expand Up @@ -238,8 +238,10 @@ func (g *GeminiInlineData) UnmarshalJSON(data []byte) error {
}

type FunctionCall struct {
ID string `json:"id,omitempty"`
FunctionName string `json:"name"`
Arguments any `json:"args"`
WillContinue *bool `json:"willContinue,omitempty"`
}

type GeminiFunctionResponse struct {
Expand Down
18 changes: 16 additions & 2 deletions dto/openai_response.go
Original file line number Diff line number Diff line change
Expand Up @@ -404,13 +404,17 @@ type ResponsesOutput struct {
CallId string `json:"call_id,omitempty"`
Name string `json:"name,omitempty"`
Arguments json.RawMessage `json:"arguments,omitempty"`
Input string `json:"input,omitempty"`
}

// ArgumentsString returns function call arguments in the string form expected by Chat Completions.
// ArgumentsString returns function arguments or custom-tool input in the string form expected by Chat Completions.
func (r *ResponsesOutput) ArgumentsString() string {
if r == nil {
return ""
}
if r.Type == BuildInCallCustomToolCall {
return r.Input
}
return ResponsesArgumentsString(r.Arguments)
}

Expand All @@ -433,11 +437,18 @@ type ResponsesReasoningSummaryPart struct {

const (
BuildInToolWebSearchPreview = "web_search_preview"
BuildInToolWebSearch = "web_search"
BuildInToolFileSearch = "file_search"
BuildInToolGoogleSearch = "google_search"
BuildInToolImageGeneration = "image_generation"
)

const (
BuildInCallWebSearchCall = "web_search_call"
BuildInCallWebSearchCall = "web_search_call"
BuildInCallFileSearchCall = "file_search_call"
BuildInCallFunctionCall = "function_call"
BuildInCallCustomToolCall = "custom_tool_call"
BuildInCallToolUse = "tool_use"
)

const (
Expand All @@ -449,6 +460,9 @@ const (
type ResponsesStreamResponse struct {
Type string `json:"type"`
Response *OpenAIResponsesResponse `json:"response,omitempty"`
Code any `json:"code,omitempty"`
Message string `json:"message,omitempty"`
Param string `json:"param,omitempty"`
Delta string `json:"delta,omitempty"`
Item *ResponsesOutput `json:"item,omitempty"`
// - response.function_call_arguments.delta
Expand Down
Loading
Loading