Skip to content
Open
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
22 changes: 10 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -169,17 +169,15 @@ func (g *Gateway) Forward(ctx context.Context, req *sdk.ForwardRequest) (sdk.For
AccountCost: 0.000035,
Currency: "USD",
Summary: "输入 10 token,输出 5 token",
Attributes: []sdk.UsageAttribute{
{Key: "reasoning_effort", Label: "思考层级", Kind: "reasoning", Value: "high"},
{Key: "resolution", Label: "分辨率", Kind: "resolution", Value: "1024x1024"},
},
Metrics: []sdk.UsageMetric{
{Key: "input_tokens", Label: "输入 token", Kind: "token", Unit: "token", Value: 10},
{Key: "output_tokens", Label: "输出 token", Kind: "token", Unit: "token", Value: 5},
},
CostDetails: []sdk.UsageCostDetail{
{Key: "input", Label: "输入费用", AccountCost: 0.00001, Currency: "USD"},
{Key: "output", Label: "输出费用", AccountCost: 0.000025, Currency: "USD"},
InputTokens: 10,
OutputTokens: 5,
InputPrice: 1,
OutputPrice: 5,
InputCost: 0.00001,
OutputCost: 0.000025,
ReasoningEffort: "high",
Metadata: map[string]string{
"openai.image.size": "1024x1024",
},
},
}, nil
Expand Down Expand Up @@ -229,7 +227,7 @@ Core 启动插件子进程

- Core 默认只管理插件生命周期、页面入口、静态资源、schema、健康检查和 API 代理。
- Gateway 插件是主请求链路,Core 会主动调用 `Forward`、账号验证和 WebSocket 处理。
- SDK 不内置平台计费规则;网关插件计算标准账号成本并填入 `Usage.AccountCost`、`Usage.Attributes`、`Usage.Metrics`、`Usage.CostDetails`。
- SDK 不内置平台计费规则;网关插件计算标准 token、单价、成本字段并填入 `Usage`,插件专属展示数据放入 `Usage.Metadata`。
- Core 统一入库后,根据用户、分组、模型等倍率写入 `UserCost` / `BillingMultiplier`;倍率规则不进入 SDK。
- 账号管理和使用记录 UI 由插件提供静态资源,Core 只加载页面、插槽和插件 API 代理,不解释平台字段。
- Middleware、扩展路由、后台任务、事件订阅、异步任务处理都属于插件显式暴露的能力;没有暴露就不会被 Core 调度。
Expand Down
4 changes: 4 additions & 0 deletions devkit/devserver/scheduler.go
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,10 @@ func (s *Scheduler) ReportResult(accountID int64, outcome sdk.ForwardOutcome) {
s.cooldown[accountID] = time.Now().Add(5 * time.Minute)
log.Printf("[调度] 账号 %d 凭证失效,冷却 5 分钟", accountID)

case sdk.OutcomeAccountUnavailable:
s.cooldown[accountID] = time.Now().Add(60 * time.Second)
log.Printf("[调度] 账号 %d 暂时 403,冷却 60 秒", accountID)

default:
delete(s.cooldown, accountID)
}
Expand Down
54 changes: 54 additions & 0 deletions devkit/devserver/scheduler_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package devserver

import (
"path/filepath"
"strconv"
"testing"

sdk "github.com/DouDOU-start/airgate-sdk/sdkgo"
)

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

store := NewAccountStore(filepath.Join(t.TempDir(), "accounts.json"))
account := store.Create(DevAccount{Name: "oauth", AccountType: "oauth"})
accountKey := strconv.FormatInt(account.ID, 10)
s := NewScheduler(store, ScheduleWeightedRR)
s.ReportResult(account.ID, sdk.ForwardOutcome{Kind: sdk.OutcomeAccountUnavailable})

status := s.Status()
cooldowns, ok := status["cooldowns"].(map[string]string)
if !ok {
t.Fatalf("cooldowns has type %T, want map[string]string", status["cooldowns"])
}
if cooldowns[accountKey] == "" {
t.Fatalf("expected account %s to be in cooldown, got %v", accountKey, cooldowns)
}

s.ReportResult(account.ID, sdk.ForwardOutcome{Kind: sdk.OutcomeSuccess})
status = s.Status()
cooldowns = status["cooldowns"].(map[string]string)
if cooldowns[accountKey] != "" {
t.Fatalf("expected success to clear cooldown, got %v", cooldowns)
}
}

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

store := NewAccountStore(filepath.Join(t.TempDir(), "accounts.json"))
account := store.Create(DevAccount{Name: "api key", AccountType: "apikey"})
accountKey := strconv.FormatInt(account.ID, 10)
s := NewScheduler(store, ScheduleWeightedRR)
s.ReportResult(account.ID, sdk.ForwardOutcome{Kind: sdk.OutcomeAccountUnavailable})

status := s.Status()
cooldowns, ok := status["cooldowns"].(map[string]string)
if !ok {
t.Fatalf("cooldowns has type %T, want map[string]string", status["cooldowns"])
}
if cooldowns[accountKey] == "" {
t.Fatalf("expected api key account to enter cooldown, got %v", cooldowns)
}
}
75 changes: 34 additions & 41 deletions docs/async-task-state-machine.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ Core 固定字段必须保持最小化,只包含任务生命周期、归属和
| `input.model` | 任务请求里的模型,用于任务复现 | `gpt-image-2` |
| `attributes.model` | 未完成任务列表里的展示/粗筛选 | `gpt-image-2` |
| `execution.*` | 插件内部工具链细节,不作为用户侧模型维度 | `tool_model=gpt-5.4` |
| `usage.Model` / `usage.Attributes.model` | 完成后的审计、计费、统计事实 | `gpt-5.4` |
| `usage.Model` / `usage.Metadata` | 完成后的审计、计费、统计事实 | `gpt-5.4` |

`Usage` 是最终事实来源。任务完成前可以用 `attributes` 暂存展示值;任务完成后应以关联的使用记录为准。

Expand Down Expand Up @@ -853,33 +853,32 @@ SDK 已经提供通用用量结构:

```go
type Usage struct {
Model string
Summary string
Attributes []UsageAttribute
Metrics []UsageMetric
CostDetails []UsageCostDetail
Metadata map[string]string
Model string
Summary string
InputTokens int
OutputTokens int
AccountCost float64
Metadata map[string]string
}
```

模型和扩展参数应进入 Usage,而不是 Core 任务固定列。对话类调用也使用同一个 Usage 结构,但不经过 Task。
模型和跨插件通用计费事实应进入 Usage 标准字段,插件专属参数进入 `Metadata`,而不是 Core 任务固定列。对话类调用也使用同一个 Usage 结构,但不经过 Task。

图片生成示例:

```json
{
"model": "gpt-image-2",
"summary": "图片生成 · gpt-image-2 · 1024x1024",
"attributes": [
{ "key": "modality", "kind": "custom", "label": "类型", "value": "image" },
{ "key": "model", "kind": "model", "label": "模型", "value": "gpt-image-2" },
{ "key": "resolution", "kind": "resolution", "label": "分辨率", "value": "1024x1024" },
{ "key": "quality", "kind": "quality", "label": "质量", "value": "high" }
],
"metrics": [
{ "key": "image_count", "kind": "image", "label": "图片张数", "unit": "image", "value": 1 },
{ "key": "output_tokens", "kind": "token", "label": "图像输出 Token", "unit": "token", "value": 4160 }
]
"output_tokens": 4160,
"output_cost": 0.1,
"account_cost": 0.1,
"metadata": {
"openai.image.size": "1024x1024",
"openai.image.count": "1",
"openai.image.unit_price": "0.1",
"openai.image.unit": "USD/image"
}
}
```

Expand All @@ -889,15 +888,12 @@ type Usage struct {
{
"model": "video-model",
"summary": "视频生成 · 8s · 1080p",
"attributes": [
{ "key": "modality", "kind": "custom", "label": "类型", "value": "video" },
{ "key": "model", "kind": "model", "label": "模型", "value": "video-model" },
{ "key": "resolution", "kind": "resolution", "label": "分辨率", "value": "1080p" },
{ "key": "quality", "kind": "quality", "label": "质量", "value": "standard" }
],
"metrics": [
{ "key": "video_seconds", "kind": "video", "label": "视频时长", "unit": "second", "value": 8 }
]
"account_cost": 0.02,
"metadata": {
"video.resolution": "1080p",
"video.seconds": "8",
"video.quality": "standard"
}
}
```

Expand All @@ -907,15 +903,12 @@ type Usage struct {
{
"model": "music-model",
"summary": "音乐生成 · 30s · high",
"attributes": [
{ "key": "modality", "kind": "custom", "label": "类型", "value": "music" },
{ "key": "model", "kind": "model", "label": "模型", "value": "music-model" },
{ "key": "quality", "kind": "quality", "label": "质量", "value": "high" },
{ "key": "format", "kind": "custom", "label": "格式", "value": "mp3" }
],
"metrics": [
{ "key": "audio_seconds", "kind": "audio", "label": "音频时长", "unit": "second", "value": 30 }
]
"account_cost": 0.01,
"metadata": {
"audio.seconds": "30",
"audio.quality": "high",
"audio.format": "mp3"
}
}
```

Expand All @@ -924,12 +917,12 @@ type Usage struct {
| 信息 | Task 中的位置 | Usage 中的位置 | 说明 |
| --- | --- | --- | --- |
| 生命周期状态 | `status`、`progress`、`stage` | 不存 | Task 独有 |
| 客户端请求参数 | `input` | 可按需复制到 `Attributes` | 完整参数以 Task input 为准 |
| 客户端请求参数 | `input` | 可按需复制到 `Metadata` | 完整参数以 Task input 为准 |
| 上游执行细节 | `execution` | 可脱敏后进入 `Metadata` | 普通用户默认不看 execution |
| 模型 | `input.model` / `attributes.model` 临时展示 | `Model` / `Attributes` | 计费与审计以 Usage 为准 |
| 分辨率、时长、质量 | `input` / `attributes` 临时展示 | `Attributes` / `Metrics` | 统计以 Usage 为准 |
| token、图片张数、视频秒数 | 不建议存 Task 固定列 | `Metrics` | 统一统计入口 |
| 成本 | 不建议存 Task 固定列 | `AccountCost` / `CostDetails` | 统一扣费入口 |
| 模型 | `input.model` / `attributes.model` 临时展示 | `Model` | 计费与审计以 Usage 为准 |
| 分辨率、时长、质量 | `input` / `attributes` 临时展示 | `Metadata` | 统计以 Usage 为准 |
| token、图片张数、视频秒数 | 不建议存 Task 固定列 | 标准 token 字段 / `Metadata` | 统一统计入口 |
| 成本 | 不建议存 Task 固定列 | `AccountCost` / 标准成本字段 | 统一扣费入口 |
| 使用记录关联 | `usage_id` | `id` | Task 完成后关联 |

列表页如果要展示未完成任务,可以读取 Task 的 `attributes`,或按插件声明的 schema 组合展示文案。完成后的历史账单、统计图、成本明细必须读取 Usage。
Expand Down
16 changes: 7 additions & 9 deletions docs/sdk-package-boundaries.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,20 +43,18 @@

## 弱契约扩展点

SDK 提供少量弱契约扩展点,用来承接展示、分类和通用计量等变化,避免为每个插件需求新增强类型字段:
SDK 提供少量弱契约扩展点,用来承接展示、分类和插件专属数据等变化,避免为每个插件需求新增强类型字段:

- `PluginInfo.Metadata`:插件市场分类、标签、展示提示等。
- `ModelInfo.Metadata`:模型家族、展示分组、供应商标签等。
- `RouteDefinition.Metadata`:路由文档链接、展示分组、调试提示等。
- `Usage.Attributes`:模型、思考层级、分辨率、质量档、服务档位等非数值审计维度。
- `Usage.Metrics`:图片张数、视频秒数、音频分钟数、工具调用次数、token 等插件计算后的通用计量结果。
- `Usage.CostDetails`:费用明细;插件填账号成本,Core 填用户扣费和倍率。
- `Usage.Metadata`:单次调用的展示或审计辅助信息。
- `Usage` 标准标量字段:模型、token、单价、账号成本、首 token 耗时、思考强度等跨插件通用事实。
- `Usage.Metadata`:单次调用的插件专属展示或审计辅助信息,使用插件命名空间 key。
- `EventHandler`:Core 向插件推送标准事件。
- `Host.Invoke` / `Host.InvokeStream`:插件用 `method + payload` 调用 Core 开放的方法,必须由 `host.invoke` 或 `host.invoke.<method>` capability 门控。
- `SchemaProvider`:插件声明 routes、tasks、events、invokes 的 payload schema;流式 Host method 用 `InvokeSchema.Transport`、`ClientFrame`、`ServerFrame` 描述传输模式和帧结构。

这些字段不能用于权限、调度、账号状态机或敏感数据传递。平台计费规则不得进入 SDK;网关插件负责计算标准账号成本 `Usage.AccountCost` / `Currency` 和审计明细。Core 统一入库、索引、汇总,并写入 `UserCost` / `BillingMultiplier`。
这些字段不能用于权限、调度、账号状态机或敏感数据传递。平台计费规则不得进入 SDK;网关插件负责计算标准账号成本 `Usage.AccountCost` / `Currency` 和标准计费字段。Core 统一入库、索引、汇总,并写入 `UserCost` / `BillingMultiplier`。

## 用量与计费边界

Expand All @@ -65,9 +63,9 @@ SDK 不提供 `CalculateCost`、价格档位、token 拆分公式或平台套餐
- 模型声明只包含 `ID`、`Name`、上下文窗口、最大输出和能力标签。
- 单次调用的标准账号成本由网关插件写入 `Usage.AccountCost` / `Usage.Currency`。
- Core 根据用户、分组、模型等倍率计算用户侧扣费,写入 `Usage.UserCost` / `Usage.BillingMultiplier`。
- 模型、思考层级、分辨率、质量档等非数值维度统一放入 `Usage.Attributes`。
- token、图片、音频、视频、请求数等数值明细统一放入 `Usage.Metrics`。
- 标准账号成本和用户扣费拆分统一放入 `Usage.CostDetails`:插件填 `AccountCost`,Core 填 `UserCost` / `BillingMultiplier`
- 跨插件通用维度优先使用 `Usage` 标准字段,例如 `Model`、token 字段、单价、成本和 `ReasoningEffort`。
- 插件专属维度放入 `Usage.Metadata`,例如 `openai.image.size` 或 `claude.cache_creation_1h_tokens`。
- 费用拆分由前端从标准成本、单价、倍率字段和 metadata 计算展示,不再通过 SDK 传输明细数组
- 使用记录和账号管理页面由插件前端与插件私有 API 实现,SDK 不定义账号用量查询 RPC。
- Core 不应把平台规则写入 SDK 或 Core 公共逻辑;Core 统一入库 `Usage`,需要页面时加载插件的静态资源和 API 代理。

Expand Down
Loading
Loading