Compare commits
No commits in common. "main" and "codex/fix-ci-runner-oom" have entirely different histories.
main
...
codex/fix-
@ -23,12 +23,6 @@ CONFIG_JWT_SECRET=this is a very secret secret
|
||||
# - hybrid: both sources are accepted and separated by gateway_users.source.
|
||||
IDENTITY_MODE=hybrid
|
||||
|
||||
# Billing engine rollout mode:
|
||||
# - observe: keep legacy billing decisions and compare effective-pricing-v2 in logs.
|
||||
# - enforce: require v2 pricing, reserve the candidate maximum, then settle asynchronously.
|
||||
# - hold: reject new production generation before any upstream request; existing settlements continue.
|
||||
BILLING_ENGINE_MODE=observe
|
||||
|
||||
# Unified identity business settings are managed in System Settings > Unified
|
||||
# Identity. Deployment only supplies the SecretStore and infrastructure timing.
|
||||
AI_GATEWAY_PUBLIC_BASE_URL=http://localhost:8088
|
||||
|
||||
@ -9,22 +9,8 @@ on:
|
||||
jobs:
|
||||
verify:
|
||||
runs-on: easyai-gateway-ci-unprivileged-v2
|
||||
services:
|
||||
postgres:
|
||||
image: docker.io/library/postgres:16-alpine@sha256:57c72fd2a128e416c7fcc499958864df5301e940bca0a56f58fddf30ffc07777
|
||||
env:
|
||||
POSTGRES_USER: easyai_test
|
||||
POSTGRES_HOST_AUTH_METHOD: trust
|
||||
POSTGRES_DB: easyai_gateway_test
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U easyai_test -d easyai_gateway_test"
|
||||
--health-interval 2s
|
||||
--health-timeout 5s
|
||||
--health-retries 30
|
||||
env:
|
||||
TRIVY_DB_REPOSITORY: ghcr.m.daocloud.io/aquasecurity/trivy-db:2
|
||||
AI_GATEWAY_DATABASE_URL: postgresql://easyai_test@postgres:5432/easyai_gateway_test?sslmode=disable
|
||||
AI_GATEWAY_TEST_DATABASE_URL: postgresql://easyai_test@postgres:5432/easyai_gateway_test?sslmode=disable
|
||||
steps:
|
||||
- name: Checkout without external Actions
|
||||
env:
|
||||
@ -93,9 +79,6 @@ jobs:
|
||||
printf 'Go files require gofmt:\n%s\n' "$unformatted" >&2
|
||||
exit 1
|
||||
}
|
||||
- name: Migrate PostgreSQL 16 integration database
|
||||
working-directory: apps/api
|
||||
run: go run ./cmd/migrate
|
||||
- name: Verify Go code
|
||||
working-directory: apps/api
|
||||
env:
|
||||
|
||||
@ -7,22 +7,8 @@ on:
|
||||
jobs:
|
||||
verify-tag:
|
||||
runs-on: easyai-gateway-ci-unprivileged-v2
|
||||
services:
|
||||
postgres:
|
||||
image: docker.io/library/postgres:16-alpine@sha256:57c72fd2a128e416c7fcc499958864df5301e940bca0a56f58fddf30ffc07777
|
||||
env:
|
||||
POSTGRES_USER: easyai_test
|
||||
POSTGRES_HOST_AUTH_METHOD: trust
|
||||
POSTGRES_DB: easyai_gateway_test
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U easyai_test -d easyai_gateway_test"
|
||||
--health-interval 2s
|
||||
--health-timeout 5s
|
||||
--health-retries 30
|
||||
env:
|
||||
TRIVY_DB_REPOSITORY: ghcr.m.daocloud.io/aquasecurity/trivy-db:2
|
||||
AI_GATEWAY_DATABASE_URL: postgresql://easyai_test@postgres:5432/easyai_gateway_test?sslmode=disable
|
||||
AI_GATEWAY_TEST_DATABASE_URL: postgresql://easyai_test@postgres:5432/easyai_gateway_test?sslmode=disable
|
||||
steps:
|
||||
- name: Checkout without external Actions
|
||||
env:
|
||||
@ -78,9 +64,6 @@ jobs:
|
||||
printf 'Go files require gofmt:\n%s\n' "$unformatted" >&2
|
||||
exit 1
|
||||
}
|
||||
- name: Migrate PostgreSQL 16 integration database
|
||||
working-directory: apps/api
|
||||
run: go run ./cmd/migrate
|
||||
- name: Verify Go code
|
||||
working-directory: apps/api
|
||||
env:
|
||||
|
||||
@ -1963,160 +1963,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/admin/runtime/billing-settlements": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
}
|
||||
],
|
||||
"description": "管理端分页查询待结算、重试失败和人工复核记录。",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"billing"
|
||||
],
|
||||
"summary": "查询计费结算队列",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "结算状态",
|
||||
"name": "status",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "动作:settle 或 release",
|
||||
"name": "action",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"default": 1,
|
||||
"description": "页码",
|
||||
"name": "page",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"default": 50,
|
||||
"description": "每页数量",
|
||||
"name": "pageSize",
|
||||
"in": "query"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/httpapi.BillingSettlementListResponse"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/httpapi.ErrorEnvelope"
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/httpapi.ErrorEnvelope"
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/httpapi.ErrorEnvelope"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/httpapi.ErrorEnvelope"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/admin/runtime/billing-settlements/{settlementId}/retry": {
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Manager 使用单值 Idempotency-Key 将重试失败或人工复核记录重新放入队列,并记录审计日志。",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"billing"
|
||||
],
|
||||
"summary": "重试计费结算",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "结算记录 ID",
|
||||
"name": "settlementId",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "幂等键",
|
||||
"name": "Idempotency-Key",
|
||||
"in": "header",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/httpapi.BillingSettlementRetryResponse"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/httpapi.ErrorEnvelope"
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/httpapi.ErrorEnvelope"
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/httpapi.ErrorEnvelope"
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Not Found",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/httpapi.ErrorEnvelope"
|
||||
}
|
||||
},
|
||||
"409": {
|
||||
"description": "Conflict",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/httpapi.ErrorEnvelope"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/httpapi.ErrorEnvelope"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/admin/runtime/model-rate-limits": {
|
||||
"get": {
|
||||
"security": [
|
||||
@ -5494,12 +5340,6 @@
|
||||
"name": "X-Async",
|
||||
"in": "header"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "可选请求幂等键;同一用户范围内唯一",
|
||||
"name": "Idempotency-Key",
|
||||
"in": "header"
|
||||
},
|
||||
{
|
||||
"description": "AI 任务请求,字段随任务类型变化",
|
||||
"name": "input",
|
||||
@ -5661,12 +5501,6 @@
|
||||
"name": "X-Async",
|
||||
"in": "header"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "可选请求幂等键;同一用户范围内唯一",
|
||||
"name": "Idempotency-Key",
|
||||
"in": "header"
|
||||
},
|
||||
{
|
||||
"description": "AI 任务请求,字段随任务类型变化",
|
||||
"name": "input",
|
||||
@ -5760,12 +5594,6 @@
|
||||
"name": "X-Async",
|
||||
"in": "header"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "可选请求幂等键;同一用户范围内唯一",
|
||||
"name": "Idempotency-Key",
|
||||
"in": "header"
|
||||
},
|
||||
{
|
||||
"description": "AI 任务请求,字段随任务类型变化",
|
||||
"name": "input",
|
||||
@ -5976,12 +5804,6 @@
|
||||
"name": "X-Async",
|
||||
"in": "header"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "可选请求幂等键;同一用户范围内唯一",
|
||||
"name": "Idempotency-Key",
|
||||
"in": "header"
|
||||
},
|
||||
{
|
||||
"description": "AI 任务请求,字段随任务类型变化",
|
||||
"name": "input",
|
||||
@ -6195,12 +6017,6 @@
|
||||
"schema": {
|
||||
"$ref": "#/definitions/httpapi.ErrorEnvelope"
|
||||
}
|
||||
},
|
||||
"503": {
|
||||
"description": "Service Unavailable",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/httpapi.ErrorEnvelope"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -6380,12 +6196,6 @@
|
||||
"name": "X-Async",
|
||||
"in": "header"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "可选请求幂等键;同一用户范围内唯一",
|
||||
"name": "Idempotency-Key",
|
||||
"in": "header"
|
||||
},
|
||||
{
|
||||
"description": "AI 任务请求,字段随任务类型变化",
|
||||
"name": "input",
|
||||
@ -6629,12 +6439,6 @@
|
||||
"name": "X-Async",
|
||||
"in": "header"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "可选请求幂等键;同一用户范围内唯一",
|
||||
"name": "Idempotency-Key",
|
||||
"in": "header"
|
||||
},
|
||||
{
|
||||
"description": "AI 任务请求,字段随任务类型变化",
|
||||
"name": "input",
|
||||
@ -6728,12 +6532,6 @@
|
||||
"name": "X-Async",
|
||||
"in": "header"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "可选请求幂等键;同一用户范围内唯一",
|
||||
"name": "Idempotency-Key",
|
||||
"in": "header"
|
||||
},
|
||||
{
|
||||
"description": "AI 任务请求,字段随任务类型变化",
|
||||
"name": "input",
|
||||
@ -7136,12 +6934,6 @@
|
||||
"name": "X-Async",
|
||||
"in": "header"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "可选请求幂等键;同一用户范围内唯一",
|
||||
"name": "Idempotency-Key",
|
||||
"in": "header"
|
||||
},
|
||||
{
|
||||
"description": "AI 任务请求,字段随任务类型变化",
|
||||
"name": "input",
|
||||
@ -7235,12 +7027,6 @@
|
||||
"name": "X-Async",
|
||||
"in": "header"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "可选请求幂等键;同一用户范围内唯一",
|
||||
"name": "Idempotency-Key",
|
||||
"in": "header"
|
||||
},
|
||||
{
|
||||
"description": "AI 任务请求,字段随任务类型变化",
|
||||
"name": "input",
|
||||
@ -8061,12 +7847,6 @@
|
||||
"name": "X-Async",
|
||||
"in": "header"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "可选请求幂等键;同一用户范围内唯一",
|
||||
"name": "Idempotency-Key",
|
||||
"in": "header"
|
||||
},
|
||||
{
|
||||
"description": "AI 任务请求,字段随任务类型变化",
|
||||
"name": "input",
|
||||
@ -8180,12 +7960,6 @@
|
||||
"name": "X-Async",
|
||||
"in": "header"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "可选请求幂等键;同一用户范围内唯一",
|
||||
"name": "Idempotency-Key",
|
||||
"in": "header"
|
||||
},
|
||||
{
|
||||
"description": "AI 任务请求,字段随任务类型变化",
|
||||
"name": "input",
|
||||
@ -8279,12 +8053,6 @@
|
||||
"name": "X-Async",
|
||||
"in": "header"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "可选请求幂等键;同一用户范围内唯一",
|
||||
"name": "Idempotency-Key",
|
||||
"in": "header"
|
||||
},
|
||||
{
|
||||
"description": "AI 任务请求,字段随任务类型变化",
|
||||
"name": "input",
|
||||
@ -8378,12 +8146,6 @@
|
||||
"name": "X-Async",
|
||||
"in": "header"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "可选请求幂等键;同一用户范围内唯一",
|
||||
"name": "Idempotency-Key",
|
||||
"in": "header"
|
||||
},
|
||||
{
|
||||
"description": "AI 任务请求,字段随任务类型变化",
|
||||
"name": "input",
|
||||
@ -8503,12 +8265,6 @@
|
||||
"name": "X-Async",
|
||||
"in": "header"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "可选请求幂等键;同一用户范围内唯一",
|
||||
"name": "Idempotency-Key",
|
||||
"in": "header"
|
||||
},
|
||||
{
|
||||
"description": "AI 任务请求,字段随任务类型变化",
|
||||
"name": "input",
|
||||
@ -8678,12 +8434,6 @@
|
||||
"name": "X-Async",
|
||||
"in": "header"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "可选请求幂等键;同一用户范围内唯一",
|
||||
"name": "Idempotency-Key",
|
||||
"in": "header"
|
||||
},
|
||||
{
|
||||
"description": "AI 任务请求,字段随任务类型变化",
|
||||
"name": "input",
|
||||
@ -8777,12 +8527,6 @@
|
||||
"name": "X-Async",
|
||||
"in": "header"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "可选请求幂等键;同一用户范围内唯一",
|
||||
"name": "Idempotency-Key",
|
||||
"in": "header"
|
||||
},
|
||||
{
|
||||
"description": "AI 任务请求,字段随任务类型变化",
|
||||
"name": "input",
|
||||
@ -9368,12 +9112,6 @@
|
||||
"name": "X-Async",
|
||||
"in": "header"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "可选请求幂等键;同一用户范围内唯一",
|
||||
"name": "Idempotency-Key",
|
||||
"in": "header"
|
||||
},
|
||||
{
|
||||
"description": "AI 任务请求,字段随任务类型变化",
|
||||
"name": "input",
|
||||
@ -9535,12 +9273,6 @@
|
||||
"name": "X-Async",
|
||||
"in": "header"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "可选请求幂等键;同一用户范围内唯一",
|
||||
"name": "Idempotency-Key",
|
||||
"in": "header"
|
||||
},
|
||||
{
|
||||
"description": "AI 任务请求,字段随任务类型变化",
|
||||
"name": "input",
|
||||
@ -9634,12 +9366,6 @@
|
||||
"name": "X-Async",
|
||||
"in": "header"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "可选请求幂等键;同一用户范围内唯一",
|
||||
"name": "Idempotency-Key",
|
||||
"in": "header"
|
||||
},
|
||||
{
|
||||
"description": "AI 任务请求,字段随任务类型变化",
|
||||
"name": "input",
|
||||
@ -9733,12 +9459,6 @@
|
||||
"name": "X-Async",
|
||||
"in": "header"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "可选请求幂等键;同一用户范围内唯一",
|
||||
"name": "Idempotency-Key",
|
||||
"in": "header"
|
||||
},
|
||||
{
|
||||
"description": "AI 任务请求,字段随任务类型变化",
|
||||
"name": "input",
|
||||
@ -9832,12 +9552,6 @@
|
||||
"name": "X-Async",
|
||||
"in": "header"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "可选请求幂等键;同一用户范围内唯一",
|
||||
"name": "Idempotency-Key",
|
||||
"in": "header"
|
||||
},
|
||||
{
|
||||
"description": "AI 任务请求,字段随任务类型变化",
|
||||
"name": "input",
|
||||
@ -10007,12 +9721,6 @@
|
||||
"name": "X-Async",
|
||||
"in": "header"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "可选请求幂等键;同一用户范围内唯一",
|
||||
"name": "Idempotency-Key",
|
||||
"in": "header"
|
||||
},
|
||||
{
|
||||
"description": "AI 任务请求,字段随任务类型变化",
|
||||
"name": "input",
|
||||
@ -10106,12 +9814,6 @@
|
||||
"name": "X-Async",
|
||||
"in": "header"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "可选请求幂等键;同一用户范围内唯一",
|
||||
"name": "Idempotency-Key",
|
||||
"in": "header"
|
||||
},
|
||||
{
|
||||
"description": "AI 任务请求,字段随任务类型变化",
|
||||
"name": "input",
|
||||
@ -10263,12 +9965,6 @@
|
||||
"name": "X-Async",
|
||||
"in": "header"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "可选请求幂等键;同一用户范围内唯一",
|
||||
"name": "Idempotency-Key",
|
||||
"in": "header"
|
||||
},
|
||||
{
|
||||
"description": "AI 任务请求,字段随任务类型变化",
|
||||
"name": "input",
|
||||
@ -10465,12 +10161,6 @@
|
||||
"name": "X-Async",
|
||||
"in": "header"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "可选请求幂等键;同一用户范围内唯一",
|
||||
"name": "Idempotency-Key",
|
||||
"in": "header"
|
||||
},
|
||||
{
|
||||
"description": "AI 任务请求,字段随任务类型变化",
|
||||
"name": "input",
|
||||
@ -10773,40 +10463,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"httpapi.BillingSettlementListResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"items": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/store.BillingSettlement"
|
||||
}
|
||||
},
|
||||
"page": {
|
||||
"type": "integer",
|
||||
"example": 1
|
||||
},
|
||||
"pageSize": {
|
||||
"type": "integer",
|
||||
"example": 50
|
||||
},
|
||||
"total": {
|
||||
"type": "integer",
|
||||
"example": 42
|
||||
}
|
||||
}
|
||||
},
|
||||
"httpapi.BillingSettlementRetryResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"auditLog": {
|
||||
"$ref": "#/definitions/store.AuditLog"
|
||||
},
|
||||
"settlement": {
|
||||
"$ref": "#/definitions/store.BillingSettlement"
|
||||
}
|
||||
}
|
||||
},
|
||||
"httpapi.CatalogProviderListResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@ -11571,14 +11227,6 @@
|
||||
"httpapi.PricingEstimateResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"candidateCount": {
|
||||
"type": "integer",
|
||||
"example": 2
|
||||
},
|
||||
"currency": {
|
||||
"type": "string",
|
||||
"example": "resource"
|
||||
},
|
||||
"items": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
@ -11586,25 +11234,9 @@
|
||||
"additionalProperties": true
|
||||
}
|
||||
},
|
||||
"pricingVersion": {
|
||||
"type": "string",
|
||||
"example": "effective-pricing-v2"
|
||||
},
|
||||
"requestFingerprint": {
|
||||
"type": "string",
|
||||
"example": "76ef6a537de8e71bd1ca93acadc078dbdbfa9f17e45224e4f9df59f535d2886f"
|
||||
},
|
||||
"reservationAmount": {
|
||||
"type": "number",
|
||||
"example": 2.75
|
||||
},
|
||||
"resolver": {
|
||||
"type": "string",
|
||||
"example": "effective-pricing-v2"
|
||||
},
|
||||
"totalAmount": {
|
||||
"type": "number",
|
||||
"example": 1.25
|
||||
"example": "effective-pricing-v1"
|
||||
}
|
||||
}
|
||||
},
|
||||
@ -12996,67 +12628,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"store.BillingSettlement": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"action": {
|
||||
"type": "string"
|
||||
},
|
||||
"amount": {
|
||||
"type": "number"
|
||||
},
|
||||
"attempts": {
|
||||
"type": "integer"
|
||||
},
|
||||
"completedAt": {
|
||||
"type": "string"
|
||||
},
|
||||
"createdAt": {
|
||||
"type": "string"
|
||||
},
|
||||
"currency": {
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"lastErrorCode": {
|
||||
"type": "string"
|
||||
},
|
||||
"lastErrorMessage": {
|
||||
"type": "string"
|
||||
},
|
||||
"lockedAt": {
|
||||
"type": "string"
|
||||
},
|
||||
"lockedBy": {
|
||||
"type": "string"
|
||||
},
|
||||
"manualReviewReason": {
|
||||
"type": "string"
|
||||
},
|
||||
"nextAttemptAt": {
|
||||
"type": "string"
|
||||
},
|
||||
"payload": {
|
||||
"type": "object",
|
||||
"additionalProperties": {}
|
||||
},
|
||||
"pricingSnapshot": {
|
||||
"type": "object",
|
||||
"additionalProperties": {}
|
||||
},
|
||||
"status": {
|
||||
"type": "string"
|
||||
},
|
||||
"taskId": {
|
||||
"type": "string"
|
||||
},
|
||||
"updatedAt": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"store.CatalogProvider": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@ -13488,25 +13059,10 @@
|
||||
"$ref": "#/definitions/store.TaskAttempt"
|
||||
}
|
||||
},
|
||||
"billingCurrency": {
|
||||
"type": "string"
|
||||
},
|
||||
"billingSettledAt": {
|
||||
"type": "string"
|
||||
},
|
||||
"billingStatus": {
|
||||
"type": "string"
|
||||
},
|
||||
"billingSummary": {
|
||||
"type": "object",
|
||||
"additionalProperties": {}
|
||||
},
|
||||
"billingUpdatedAt": {
|
||||
"type": "string"
|
||||
},
|
||||
"billingVersion": {
|
||||
"type": "string"
|
||||
},
|
||||
"billings": {
|
||||
"type": "array",
|
||||
"items": {}
|
||||
@ -13529,9 +13085,6 @@
|
||||
"errorMessage": {
|
||||
"type": "string"
|
||||
},
|
||||
"executionLeaseExpiresAt": {
|
||||
"type": "string"
|
||||
},
|
||||
"finalChargeAmount": {
|
||||
"type": "number"
|
||||
},
|
||||
@ -13566,10 +13119,6 @@
|
||||
"newMessageCount": {
|
||||
"type": "integer"
|
||||
},
|
||||
"pricingSnapshot": {
|
||||
"type": "object",
|
||||
"additionalProperties": {}
|
||||
},
|
||||
"remoteTaskId": {
|
||||
"type": "string"
|
||||
},
|
||||
@ -13581,18 +13130,12 @@
|
||||
"type": "object",
|
||||
"additionalProperties": {}
|
||||
},
|
||||
"requestFingerprint": {
|
||||
"type": "string"
|
||||
},
|
||||
"requestId": {
|
||||
"type": "string"
|
||||
},
|
||||
"requestedModel": {
|
||||
"type": "string"
|
||||
},
|
||||
"reservationAmount": {
|
||||
"type": "number"
|
||||
},
|
||||
"resolvedModel": {
|
||||
"type": "string"
|
||||
},
|
||||
@ -14423,12 +13966,6 @@
|
||||
"type": "object",
|
||||
"additionalProperties": {}
|
||||
},
|
||||
"effectiveFrom": {
|
||||
"type": "string"
|
||||
},
|
||||
"effectiveTo": {
|
||||
"type": "string"
|
||||
},
|
||||
"formulaConfig": {
|
||||
"type": "object",
|
||||
"additionalProperties": {}
|
||||
@ -14436,9 +13973,6 @@
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"isFree": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"metadata": {
|
||||
"type": "object",
|
||||
"additionalProperties": {}
|
||||
@ -14499,19 +14033,10 @@
|
||||
"type": "object",
|
||||
"additionalProperties": {}
|
||||
},
|
||||
"effectiveFrom": {
|
||||
"type": "string"
|
||||
},
|
||||
"effectiveTo": {
|
||||
"type": "string"
|
||||
},
|
||||
"formulaConfig": {
|
||||
"type": "object",
|
||||
"additionalProperties": {}
|
||||
},
|
||||
"isFree": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"metadata": {
|
||||
"type": "object",
|
||||
"additionalProperties": {}
|
||||
@ -14933,10 +14458,6 @@
|
||||
"platformName": {
|
||||
"type": "string"
|
||||
},
|
||||
"pricingSnapshot": {
|
||||
"type": "object",
|
||||
"additionalProperties": {}
|
||||
},
|
||||
"provider": {
|
||||
"type": "string"
|
||||
},
|
||||
@ -14946,9 +14467,6 @@
|
||||
"queueKey": {
|
||||
"type": "string"
|
||||
},
|
||||
"requestFingerprint": {
|
||||
"type": "string"
|
||||
},
|
||||
"requestId": {
|
||||
"type": "string"
|
||||
},
|
||||
@ -14987,12 +14505,6 @@
|
||||
"taskId": {
|
||||
"type": "string"
|
||||
},
|
||||
"upstreamSubmissionStatus": {
|
||||
"type": "string"
|
||||
},
|
||||
"upstreamSubmissionUpdatedAt": {
|
||||
"type": "string"
|
||||
},
|
||||
"usage": {
|
||||
"type": "object",
|
||||
"additionalProperties": {}
|
||||
|
||||
@ -85,29 +85,6 @@ definitions:
|
||||
$ref: '#/definitions/store.BaseModel'
|
||||
type: array
|
||||
type: object
|
||||
httpapi.BillingSettlementListResponse:
|
||||
properties:
|
||||
items:
|
||||
items:
|
||||
$ref: '#/definitions/store.BillingSettlement'
|
||||
type: array
|
||||
page:
|
||||
example: 1
|
||||
type: integer
|
||||
pageSize:
|
||||
example: 50
|
||||
type: integer
|
||||
total:
|
||||
example: 42
|
||||
type: integer
|
||||
type: object
|
||||
httpapi.BillingSettlementRetryResponse:
|
||||
properties:
|
||||
auditLog:
|
||||
$ref: '#/definitions/store.AuditLog'
|
||||
settlement:
|
||||
$ref: '#/definitions/store.BillingSettlement'
|
||||
type: object
|
||||
httpapi.CatalogProviderListResponse:
|
||||
properties:
|
||||
items:
|
||||
@ -633,32 +610,14 @@ definitions:
|
||||
type: object
|
||||
httpapi.PricingEstimateResponse:
|
||||
properties:
|
||||
candidateCount:
|
||||
example: 2
|
||||
type: integer
|
||||
currency:
|
||||
example: resource
|
||||
type: string
|
||||
items:
|
||||
items:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
type: array
|
||||
pricingVersion:
|
||||
example: effective-pricing-v2
|
||||
type: string
|
||||
requestFingerprint:
|
||||
example: 76ef6a537de8e71bd1ca93acadc078dbdbfa9f17e45224e4f9df59f535d2886f
|
||||
type: string
|
||||
reservationAmount:
|
||||
example: 2.75
|
||||
type: number
|
||||
resolver:
|
||||
example: effective-pricing-v2
|
||||
example: effective-pricing-v1
|
||||
type: string
|
||||
totalAmount:
|
||||
example: 1.25
|
||||
type: number
|
||||
type: object
|
||||
httpapi.PricingRuleListResponse:
|
||||
properties:
|
||||
@ -1622,47 +1581,6 @@ definitions:
|
||||
status:
|
||||
type: string
|
||||
type: object
|
||||
store.BillingSettlement:
|
||||
properties:
|
||||
action:
|
||||
type: string
|
||||
amount:
|
||||
type: number
|
||||
attempts:
|
||||
type: integer
|
||||
completedAt:
|
||||
type: string
|
||||
createdAt:
|
||||
type: string
|
||||
currency:
|
||||
type: string
|
||||
id:
|
||||
type: string
|
||||
lastErrorCode:
|
||||
type: string
|
||||
lastErrorMessage:
|
||||
type: string
|
||||
lockedAt:
|
||||
type: string
|
||||
lockedBy:
|
||||
type: string
|
||||
manualReviewReason:
|
||||
type: string
|
||||
nextAttemptAt:
|
||||
type: string
|
||||
payload:
|
||||
additionalProperties: {}
|
||||
type: object
|
||||
pricingSnapshot:
|
||||
additionalProperties: {}
|
||||
type: object
|
||||
status:
|
||||
type: string
|
||||
taskId:
|
||||
type: string
|
||||
updatedAt:
|
||||
type: string
|
||||
type: object
|
||||
store.CatalogProvider:
|
||||
properties:
|
||||
capabilitySchema:
|
||||
@ -1954,19 +1872,9 @@ definitions:
|
||||
items:
|
||||
$ref: '#/definitions/store.TaskAttempt'
|
||||
type: array
|
||||
billingCurrency:
|
||||
type: string
|
||||
billingSettledAt:
|
||||
type: string
|
||||
billingStatus:
|
||||
type: string
|
||||
billingSummary:
|
||||
additionalProperties: {}
|
||||
type: object
|
||||
billingUpdatedAt:
|
||||
type: string
|
||||
billingVersion:
|
||||
type: string
|
||||
billings:
|
||||
items: {}
|
||||
type: array
|
||||
@ -1982,8 +1890,6 @@ definitions:
|
||||
type: string
|
||||
errorMessage:
|
||||
type: string
|
||||
executionLeaseExpiresAt:
|
||||
type: string
|
||||
finalChargeAmount:
|
||||
type: number
|
||||
finishedAt:
|
||||
@ -2007,9 +1913,6 @@ definitions:
|
||||
type: string
|
||||
newMessageCount:
|
||||
type: integer
|
||||
pricingSnapshot:
|
||||
additionalProperties: {}
|
||||
type: object
|
||||
remoteTaskId:
|
||||
type: string
|
||||
remoteTaskPayload:
|
||||
@ -2018,14 +1921,10 @@ definitions:
|
||||
request:
|
||||
additionalProperties: {}
|
||||
type: object
|
||||
requestFingerprint:
|
||||
type: string
|
||||
requestId:
|
||||
type: string
|
||||
requestedModel:
|
||||
type: string
|
||||
reservationAmount:
|
||||
type: number
|
||||
resolvedModel:
|
||||
type: string
|
||||
responseDurationMs:
|
||||
@ -2586,17 +2485,11 @@ definitions:
|
||||
dynamicWeight:
|
||||
additionalProperties: {}
|
||||
type: object
|
||||
effectiveFrom:
|
||||
type: string
|
||||
effectiveTo:
|
||||
type: string
|
||||
formulaConfig:
|
||||
additionalProperties: {}
|
||||
type: object
|
||||
id:
|
||||
type: string
|
||||
isFree:
|
||||
type: boolean
|
||||
metadata:
|
||||
additionalProperties: {}
|
||||
type: object
|
||||
@ -2638,15 +2531,9 @@ definitions:
|
||||
dynamicWeight:
|
||||
additionalProperties: {}
|
||||
type: object
|
||||
effectiveFrom:
|
||||
type: string
|
||||
effectiveTo:
|
||||
type: string
|
||||
formulaConfig:
|
||||
additionalProperties: {}
|
||||
type: object
|
||||
isFree:
|
||||
type: boolean
|
||||
metadata:
|
||||
additionalProperties: {}
|
||||
type: object
|
||||
@ -2933,17 +2820,12 @@ definitions:
|
||||
type: string
|
||||
platformName:
|
||||
type: string
|
||||
pricingSnapshot:
|
||||
additionalProperties: {}
|
||||
type: object
|
||||
provider:
|
||||
type: string
|
||||
providerModelName:
|
||||
type: string
|
||||
queueKey:
|
||||
type: string
|
||||
requestFingerprint:
|
||||
type: string
|
||||
requestId:
|
||||
type: string
|
||||
requestSnapshot:
|
||||
@ -2970,10 +2852,6 @@ definitions:
|
||||
type: integer
|
||||
taskId:
|
||||
type: string
|
||||
upstreamSubmissionStatus:
|
||||
type: string
|
||||
upstreamSubmissionUpdatedAt:
|
||||
type: string
|
||||
usage:
|
||||
additionalProperties: {}
|
||||
type: object
|
||||
@ -4356,106 +4234,6 @@ paths:
|
||||
summary: 列出定价规则
|
||||
tags:
|
||||
- pricing
|
||||
/api/admin/runtime/billing-settlements:
|
||||
get:
|
||||
description: 管理端分页查询待结算、重试失败和人工复核记录。
|
||||
parameters:
|
||||
- description: 结算状态
|
||||
in: query
|
||||
name: status
|
||||
type: string
|
||||
- description: 动作:settle 或 release
|
||||
in: query
|
||||
name: action
|
||||
type: string
|
||||
- default: 1
|
||||
description: 页码
|
||||
in: query
|
||||
name: page
|
||||
type: integer
|
||||
- default: 50
|
||||
description: 每页数量
|
||||
in: query
|
||||
name: pageSize
|
||||
type: integer
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
$ref: '#/definitions/httpapi.BillingSettlementListResponse'
|
||||
"400":
|
||||
description: Bad Request
|
||||
schema:
|
||||
$ref: '#/definitions/httpapi.ErrorEnvelope'
|
||||
"401":
|
||||
description: Unauthorized
|
||||
schema:
|
||||
$ref: '#/definitions/httpapi.ErrorEnvelope'
|
||||
"403":
|
||||
description: Forbidden
|
||||
schema:
|
||||
$ref: '#/definitions/httpapi.ErrorEnvelope'
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
$ref: '#/definitions/httpapi.ErrorEnvelope'
|
||||
security:
|
||||
- BearerAuth: []
|
||||
summary: 查询计费结算队列
|
||||
tags:
|
||||
- billing
|
||||
/api/admin/runtime/billing-settlements/{settlementId}/retry:
|
||||
post:
|
||||
description: Manager 使用单值 Idempotency-Key 将重试失败或人工复核记录重新放入队列,并记录审计日志。
|
||||
parameters:
|
||||
- description: 结算记录 ID
|
||||
in: path
|
||||
name: settlementId
|
||||
required: true
|
||||
type: string
|
||||
- description: 幂等键
|
||||
in: header
|
||||
name: Idempotency-Key
|
||||
required: true
|
||||
type: string
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
$ref: '#/definitions/httpapi.BillingSettlementRetryResponse'
|
||||
"400":
|
||||
description: Bad Request
|
||||
schema:
|
||||
$ref: '#/definitions/httpapi.ErrorEnvelope'
|
||||
"401":
|
||||
description: Unauthorized
|
||||
schema:
|
||||
$ref: '#/definitions/httpapi.ErrorEnvelope'
|
||||
"403":
|
||||
description: Forbidden
|
||||
schema:
|
||||
$ref: '#/definitions/httpapi.ErrorEnvelope'
|
||||
"404":
|
||||
description: Not Found
|
||||
schema:
|
||||
$ref: '#/definitions/httpapi.ErrorEnvelope'
|
||||
"409":
|
||||
description: Conflict
|
||||
schema:
|
||||
$ref: '#/definitions/httpapi.ErrorEnvelope'
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
$ref: '#/definitions/httpapi.ErrorEnvelope'
|
||||
security:
|
||||
- BearerAuth: []
|
||||
summary: 重试计费结算
|
||||
tags:
|
||||
- billing
|
||||
/api/admin/runtime/model-rate-limits:
|
||||
get:
|
||||
description: 管理端查看平台模型维度的限流和冷却状态。
|
||||
@ -6624,10 +6402,6 @@ paths:
|
||||
in: header
|
||||
name: X-Async
|
||||
type: boolean
|
||||
- description: 可选请求幂等键;同一用户范围内唯一
|
||||
in: header
|
||||
name: Idempotency-Key
|
||||
type: string
|
||||
- description: AI 任务请求,字段随任务类型变化
|
||||
in: body
|
||||
name: input
|
||||
@ -6733,10 +6507,6 @@ paths:
|
||||
in: header
|
||||
name: X-Async
|
||||
type: boolean
|
||||
- description: 可选请求幂等键;同一用户范围内唯一
|
||||
in: header
|
||||
name: Idempotency-Key
|
||||
type: string
|
||||
- description: AI 任务请求,字段随任务类型变化
|
||||
in: body
|
||||
name: input
|
||||
@ -6798,10 +6568,6 @@ paths:
|
||||
in: header
|
||||
name: X-Async
|
||||
type: boolean
|
||||
- description: 可选请求幂等键;同一用户范围内唯一
|
||||
in: header
|
||||
name: Idempotency-Key
|
||||
type: string
|
||||
- description: AI 任务请求,字段随任务类型变化
|
||||
in: body
|
||||
name: input
|
||||
@ -6936,10 +6702,6 @@ paths:
|
||||
in: header
|
||||
name: X-Async
|
||||
type: boolean
|
||||
- description: 可选请求幂等键;同一用户范围内唯一
|
||||
in: header
|
||||
name: Idempotency-Key
|
||||
type: string
|
||||
- description: AI 任务请求,字段随任务类型变化
|
||||
in: body
|
||||
name: input
|
||||
@ -7079,10 +6841,6 @@ paths:
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
$ref: '#/definitions/httpapi.ErrorEnvelope'
|
||||
"503":
|
||||
description: Service Unavailable
|
||||
schema:
|
||||
$ref: '#/definitions/httpapi.ErrorEnvelope'
|
||||
security:
|
||||
- BearerAuth: []
|
||||
summary: 估算请求价格
|
||||
@ -7197,10 +6955,6 @@ paths:
|
||||
in: header
|
||||
name: X-Async
|
||||
type: boolean
|
||||
- description: 可选请求幂等键;同一用户范围内唯一
|
||||
in: header
|
||||
name: Idempotency-Key
|
||||
type: string
|
||||
- description: AI 任务请求,字段随任务类型变化
|
||||
in: body
|
||||
name: input
|
||||
@ -7365,10 +7119,6 @@ paths:
|
||||
in: header
|
||||
name: X-Async
|
||||
type: boolean
|
||||
- description: 可选请求幂等键;同一用户范围内唯一
|
||||
in: header
|
||||
name: Idempotency-Key
|
||||
type: string
|
||||
- description: AI 任务请求,字段随任务类型变化
|
||||
in: body
|
||||
name: input
|
||||
@ -7430,10 +7180,6 @@ paths:
|
||||
in: header
|
||||
name: X-Async
|
||||
type: boolean
|
||||
- description: 可选请求幂等键;同一用户范围内唯一
|
||||
in: header
|
||||
name: Idempotency-Key
|
||||
type: string
|
||||
- description: AI 任务请求,字段随任务类型变化
|
||||
in: body
|
||||
name: input
|
||||
@ -7693,10 +7439,6 @@ paths:
|
||||
in: header
|
||||
name: X-Async
|
||||
type: boolean
|
||||
- description: 可选请求幂等键;同一用户范围内唯一
|
||||
in: header
|
||||
name: Idempotency-Key
|
||||
type: string
|
||||
- description: AI 任务请求,字段随任务类型变化
|
||||
in: body
|
||||
name: input
|
||||
@ -7758,10 +7500,6 @@ paths:
|
||||
in: header
|
||||
name: X-Async
|
||||
type: boolean
|
||||
- description: 可选请求幂等键;同一用户范围内唯一
|
||||
in: header
|
||||
name: Idempotency-Key
|
||||
type: string
|
||||
- description: AI 任务请求,字段随任务类型变化
|
||||
in: body
|
||||
name: input
|
||||
@ -8290,10 +8028,6 @@ paths:
|
||||
in: header
|
||||
name: X-Async
|
||||
type: boolean
|
||||
- description: 可选请求幂等键;同一用户范围内唯一
|
||||
in: header
|
||||
name: Idempotency-Key
|
||||
type: string
|
||||
- description: AI 任务请求,字段随任务类型变化
|
||||
in: body
|
||||
name: input
|
||||
@ -8368,10 +8102,6 @@ paths:
|
||||
in: header
|
||||
name: X-Async
|
||||
type: boolean
|
||||
- description: 可选请求幂等键;同一用户范围内唯一
|
||||
in: header
|
||||
name: Idempotency-Key
|
||||
type: string
|
||||
- description: AI 任务请求,字段随任务类型变化
|
||||
in: body
|
||||
name: input
|
||||
@ -8433,10 +8163,6 @@ paths:
|
||||
in: header
|
||||
name: X-Async
|
||||
type: boolean
|
||||
- description: 可选请求幂等键;同一用户范围内唯一
|
||||
in: header
|
||||
name: Idempotency-Key
|
||||
type: string
|
||||
- description: AI 任务请求,字段随任务类型变化
|
||||
in: body
|
||||
name: input
|
||||
@ -8498,10 +8224,6 @@ paths:
|
||||
in: header
|
||||
name: X-Async
|
||||
type: boolean
|
||||
- description: 可选请求幂等键;同一用户范围内唯一
|
||||
in: header
|
||||
name: Idempotency-Key
|
||||
type: string
|
||||
- description: AI 任务请求,字段随任务类型变化
|
||||
in: body
|
||||
name: input
|
||||
@ -8580,10 +8302,6 @@ paths:
|
||||
in: header
|
||||
name: X-Async
|
||||
type: boolean
|
||||
- description: 可选请求幂等键;同一用户范围内唯一
|
||||
in: header
|
||||
name: Idempotency-Key
|
||||
type: string
|
||||
- description: AI 任务请求,字段随任务类型变化
|
||||
in: body
|
||||
name: input
|
||||
@ -8697,10 +8415,6 @@ paths:
|
||||
in: header
|
||||
name: X-Async
|
||||
type: boolean
|
||||
- description: 可选请求幂等键;同一用户范围内唯一
|
||||
in: header
|
||||
name: Idempotency-Key
|
||||
type: string
|
||||
- description: AI 任务请求,字段随任务类型变化
|
||||
in: body
|
||||
name: input
|
||||
@ -8762,10 +8476,6 @@ paths:
|
||||
in: header
|
||||
name: X-Async
|
||||
type: boolean
|
||||
- description: 可选请求幂等键;同一用户范围内唯一
|
||||
in: header
|
||||
name: Idempotency-Key
|
||||
type: string
|
||||
- description: AI 任务请求,字段随任务类型变化
|
||||
in: body
|
||||
name: input
|
||||
@ -9146,10 +8856,6 @@ paths:
|
||||
in: header
|
||||
name: X-Async
|
||||
type: boolean
|
||||
- description: 可选请求幂等键;同一用户范围内唯一
|
||||
in: header
|
||||
name: Idempotency-Key
|
||||
type: string
|
||||
- description: AI 任务请求,字段随任务类型变化
|
||||
in: body
|
||||
name: input
|
||||
@ -9255,10 +8961,6 @@ paths:
|
||||
in: header
|
||||
name: X-Async
|
||||
type: boolean
|
||||
- description: 可选请求幂等键;同一用户范围内唯一
|
||||
in: header
|
||||
name: Idempotency-Key
|
||||
type: string
|
||||
- description: AI 任务请求,字段随任务类型变化
|
||||
in: body
|
||||
name: input
|
||||
@ -9320,10 +9022,6 @@ paths:
|
||||
in: header
|
||||
name: X-Async
|
||||
type: boolean
|
||||
- description: 可选请求幂等键;同一用户范围内唯一
|
||||
in: header
|
||||
name: Idempotency-Key
|
||||
type: string
|
||||
- description: AI 任务请求,字段随任务类型变化
|
||||
in: body
|
||||
name: input
|
||||
@ -9385,10 +9083,6 @@ paths:
|
||||
in: header
|
||||
name: X-Async
|
||||
type: boolean
|
||||
- description: 可选请求幂等键;同一用户范围内唯一
|
||||
in: header
|
||||
name: Idempotency-Key
|
||||
type: string
|
||||
- description: AI 任务请求,字段随任务类型变化
|
||||
in: body
|
||||
name: input
|
||||
@ -9450,10 +9144,6 @@ paths:
|
||||
in: header
|
||||
name: X-Async
|
||||
type: boolean
|
||||
- description: 可选请求幂等键;同一用户范围内唯一
|
||||
in: header
|
||||
name: Idempotency-Key
|
||||
type: string
|
||||
- description: AI 任务请求,字段随任务类型变化
|
||||
in: body
|
||||
name: input
|
||||
@ -9567,10 +9257,6 @@ paths:
|
||||
in: header
|
||||
name: X-Async
|
||||
type: boolean
|
||||
- description: 可选请求幂等键;同一用户范围内唯一
|
||||
in: header
|
||||
name: Idempotency-Key
|
||||
type: string
|
||||
- description: AI 任务请求,字段随任务类型变化
|
||||
in: body
|
||||
name: input
|
||||
@ -9632,10 +9318,6 @@ paths:
|
||||
in: header
|
||||
name: X-Async
|
||||
type: boolean
|
||||
- description: 可选请求幂等键;同一用户范围内唯一
|
||||
in: header
|
||||
name: Idempotency-Key
|
||||
type: string
|
||||
- description: AI 任务请求,字段随任务类型变化
|
||||
in: body
|
||||
name: input
|
||||
@ -9734,10 +9416,6 @@ paths:
|
||||
in: header
|
||||
name: X-Async
|
||||
type: boolean
|
||||
- description: 可选请求幂等键;同一用户范围内唯一
|
||||
in: header
|
||||
name: Idempotency-Key
|
||||
type: string
|
||||
- description: AI 任务请求,字段随任务类型变化
|
||||
in: body
|
||||
name: input
|
||||
@ -9865,10 +9543,6 @@ paths:
|
||||
in: header
|
||||
name: X-Async
|
||||
type: boolean
|
||||
- description: 可选请求幂等键;同一用户范围内唯一
|
||||
in: header
|
||||
name: Idempotency-Key
|
||||
type: string
|
||||
- description: AI 任务请求,字段随任务类型变化
|
||||
in: body
|
||||
name: input
|
||||
|
||||
@ -34,11 +34,6 @@ var gatewayOpenAIRequestExtensions = stringSet(
|
||||
"requestId", "request_id", "signal", "userMessage", "user_message", "platformId",
|
||||
"platform_id", "options", "enable_thinking", "thinking_budget_tokens", "enable_web_search",
|
||||
"modelType", "model_type", "capability", "capabilityType", "mode", "simulation", "testMode",
|
||||
"cacheAffinityKey", "cache_affinity_key", "simulationDurationMs", "simulationDurationSeconds",
|
||||
"simulationMinDurationMs", "simulationMaxDurationMs", "simulationMinDurationSeconds",
|
||||
"simulationMaxDurationSeconds", "simulationDurationMinMs", "simulationDurationMaxMs",
|
||||
"simulationDurationMinSeconds", "simulationDurationMaxSeconds", "simulationFailure",
|
||||
"simulationProfile", "simulationUsage",
|
||||
)
|
||||
|
||||
var gatewayResponsesRequestExtensions = stringSet("messages", "presence_penalty", "frequency_penalty")
|
||||
|
||||
@ -60,23 +60,6 @@ func TestValidateOpenAIRequestParametersRejectsUnknownTopLevelField(t *testing.T
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateOpenAIRequestParametersAcceptsInternalSimulationAndAffinityFields(t *testing.T) {
|
||||
body := map[string]any{
|
||||
"model": "demo", "messages": []any{}, "simulation": true,
|
||||
"simulationDurationMs": 5, "simulationUsage": map[string]any{"inputTokens": 10},
|
||||
"cacheAffinityKey": "conversation-1",
|
||||
}
|
||||
if err := ValidateOpenAIRequestParameters("chat.completions", body); err != nil {
|
||||
t.Fatalf("expected documented gateway extensions to remain accepted, got %v", err)
|
||||
}
|
||||
filtered := FilterOpenAIChatRequestBody(body)
|
||||
for _, key := range []string{"simulation", "simulationDurationMs", "simulationUsage", "cacheAffinityKey"} {
|
||||
if _, ok := filtered[key]; ok {
|
||||
t.Fatalf("gateway-only field %q leaked upstream", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResponsesFallbackMapsEquivalentCurrentParameters(t *testing.T) {
|
||||
body, err := ResponsesRequestToChat(map[string]any{
|
||||
"input": "hello", "store": false, "metadata": map[string]any{"trace": "1"},
|
||||
|
||||
@ -36,7 +36,6 @@ type ResponseTurn struct {
|
||||
}
|
||||
|
||||
type Response struct {
|
||||
AttemptID string
|
||||
Result map[string]any
|
||||
RequestID string
|
||||
Usage Usage
|
||||
|
||||
@ -47,7 +47,6 @@ type Config struct {
|
||||
GlobalHTTPProxy string
|
||||
GlobalHTTPProxySource string
|
||||
LogLevel slog.Level
|
||||
BillingEngineMode string
|
||||
}
|
||||
|
||||
func Load() Config {
|
||||
@ -91,16 +90,10 @@ func Load() Config {
|
||||
GlobalHTTPProxy: globalProxy.HTTPProxy,
|
||||
GlobalHTTPProxySource: globalProxy.Source,
|
||||
LogLevel: logLevel(env("LOG_LEVEL", "info")),
|
||||
BillingEngineMode: strings.ToLower(env("BILLING_ENGINE_MODE", "observe")),
|
||||
}
|
||||
}
|
||||
|
||||
func (c Config) Validate() error {
|
||||
switch strings.ToLower(strings.TrimSpace(c.BillingEngineMode)) {
|
||||
case "", "observe", "enforce", "hold":
|
||||
default:
|
||||
return errors.New("BILLING_ENGINE_MODE must be observe, enforce, or hold")
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(c.IdentitySecretStore)) {
|
||||
case "":
|
||||
case "file":
|
||||
|
||||
@ -13,7 +13,7 @@ import (
|
||||
|
||||
type walletBalanceRequest struct {
|
||||
Currency string `json:"currency" example:"USD"`
|
||||
Balance json.Number `json:"balance" swaggertype:"number" example:"100"`
|
||||
Balance float64 `json:"balance" example:"100"`
|
||||
Reason string `json:"reason" example:"manual recharge"`
|
||||
IdempotencyKey string `json:"idempotencyKey" example:"wallet-set-20260514-001"`
|
||||
Metadata map[string]any `json:"metadata"`
|
||||
@ -21,7 +21,7 @@ type walletBalanceRequest struct {
|
||||
|
||||
type walletRechargeRequest struct {
|
||||
Currency string `json:"currency" example:"resource"`
|
||||
Amount json.Number `json:"amount" swaggertype:"number" example:"100"`
|
||||
Amount float64 `json:"amount" example:"100"`
|
||||
Reason string `json:"reason" example:"manual recharge"`
|
||||
IdempotencyKey string `json:"idempotencyKey" example:"wallet-recharge-20260514-001"`
|
||||
Metadata map[string]any `json:"metadata"`
|
||||
@ -50,6 +50,10 @@ func (s *Server) setUserWalletBalance(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusBadRequest, "invalid json body")
|
||||
return
|
||||
}
|
||||
if input.Balance < 0 {
|
||||
writeError(w, http.StatusBadRequest, "wallet balance cannot be negative")
|
||||
return
|
||||
}
|
||||
gatewayUserID := strings.TrimSpace(r.PathValue("userID"))
|
||||
reason := strings.TrimSpace(input.Reason)
|
||||
if reason == "" {
|
||||
@ -63,7 +67,7 @@ func (s *Server) setUserWalletBalance(w http.ResponseWriter, r *http.Request) {
|
||||
next, err := s.store.SetUserWalletBalanceTx(r.Context(), tx, store.WalletBalanceAdjustmentInput{
|
||||
GatewayUserID: gatewayUserID,
|
||||
Currency: input.Currency,
|
||||
BalanceText: input.Balance.String(),
|
||||
Balance: input.Balance,
|
||||
Reason: reason,
|
||||
IdempotencyKey: input.IdempotencyKey,
|
||||
Metadata: input.Metadata,
|
||||
@ -85,10 +89,6 @@ func (s *Server) setUserWalletBalance(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusNotFound, "user not found")
|
||||
case errors.Is(err, store.ErrWalletBalanceUnchanged):
|
||||
writeError(w, http.StatusBadRequest, "wallet balance is unchanged")
|
||||
case errors.Is(err, store.ErrInvalidWalletAmount):
|
||||
writeError(w, http.StatusBadRequest, "wallet balance must be a non-negative decimal with at most nine fractional digits", "invalid_wallet_amount")
|
||||
case errors.Is(err, store.ErrBalanceBelowFrozen):
|
||||
writeError(w, http.StatusConflict, "wallet balance cannot be below frozen balance", "balance_below_frozen")
|
||||
default:
|
||||
s.logger.Error("set user wallet balance failed", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "set user wallet balance failed")
|
||||
@ -126,6 +126,10 @@ func (s *Server) rechargeUserWalletBalance(w http.ResponseWriter, r *http.Reques
|
||||
writeError(w, http.StatusBadRequest, "invalid json body")
|
||||
return
|
||||
}
|
||||
if input.Amount <= 0 {
|
||||
writeError(w, http.StatusBadRequest, "wallet recharge amount must be positive")
|
||||
return
|
||||
}
|
||||
gatewayUserID := strings.TrimSpace(r.PathValue("userID"))
|
||||
reason := strings.TrimSpace(input.Reason)
|
||||
if reason == "" {
|
||||
@ -139,7 +143,7 @@ func (s *Server) rechargeUserWalletBalance(w http.ResponseWriter, r *http.Reques
|
||||
next, err := s.store.RechargeUserWalletBalanceTx(r.Context(), tx, store.WalletRechargeInput{
|
||||
GatewayUserID: gatewayUserID,
|
||||
Currency: input.Currency,
|
||||
AmountText: input.Amount.String(),
|
||||
Amount: input.Amount,
|
||||
Reason: reason,
|
||||
IdempotencyKey: input.IdempotencyKey,
|
||||
Metadata: input.Metadata,
|
||||
@ -159,8 +163,6 @@ func (s *Server) rechargeUserWalletBalance(w http.ResponseWriter, r *http.Reques
|
||||
switch {
|
||||
case store.IsNotFound(err):
|
||||
writeError(w, http.StatusNotFound, "user not found")
|
||||
case errors.Is(err, store.ErrInvalidWalletAmount):
|
||||
writeError(w, http.StatusBadRequest, "wallet recharge amount must be a positive decimal with at most nine fractional digits", "invalid_wallet_amount")
|
||||
default:
|
||||
s.logger.Error("recharge user wallet balance failed", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "recharge user wallet balance failed")
|
||||
|
||||
@ -1,145 +0,0 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// listBillingSettlements godoc
|
||||
// @Summary 查询计费结算队列
|
||||
// @Description 管理端分页查询待结算、重试失败和人工复核记录。
|
||||
// @Tags billing
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param status query string false "结算状态"
|
||||
// @Param action query string false "动作:settle 或 release"
|
||||
// @Param page query int false "页码" default(1)
|
||||
// @Param pageSize query int false "每页数量" default(50)
|
||||
// @Success 200 {object} BillingSettlementListResponse
|
||||
// @Failure 400 {object} ErrorEnvelope
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Failure 403 {object} ErrorEnvelope
|
||||
// @Failure 500 {object} ErrorEnvelope
|
||||
// @Router /api/admin/runtime/billing-settlements [get]
|
||||
func (s *Server) listBillingSettlements(w http.ResponseWriter, r *http.Request) {
|
||||
query := r.URL.Query()
|
||||
page, err := positiveQueryInt(query.Get("page"), 1)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid page", "invalid_request")
|
||||
return
|
||||
}
|
||||
pageSize, err := positiveQueryInt(query.Get("pageSize"), 50)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid pageSize", "invalid_request")
|
||||
return
|
||||
}
|
||||
result, err := s.store.ListBillingSettlements(r.Context(), store.BillingSettlementListFilter{
|
||||
Status: query.Get("status"), Action: query.Get("action"), Page: page, PageSize: pageSize,
|
||||
})
|
||||
if err != nil {
|
||||
s.logger.Error("list billing settlements failed", "error_category", "billing_settlement_list_failed")
|
||||
writeError(w, http.StatusInternalServerError, "list billing settlements failed", "billing_settlement_list_failed")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, result)
|
||||
}
|
||||
|
||||
// retryBillingSettlement godoc
|
||||
// @Summary 重试计费结算
|
||||
// @Description Manager 使用单值 Idempotency-Key 将重试失败或人工复核记录重新放入队列,并记录审计日志。
|
||||
// @Tags billing
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param settlementId path string true "结算记录 ID"
|
||||
// @Param Idempotency-Key header string true "幂等键"
|
||||
// @Success 200 {object} BillingSettlementRetryResponse
|
||||
// @Failure 400 {object} ErrorEnvelope
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Failure 403 {object} ErrorEnvelope
|
||||
// @Failure 404 {object} ErrorEnvelope
|
||||
// @Failure 409 {object} ErrorEnvelope
|
||||
// @Failure 500 {object} ErrorEnvelope
|
||||
// @Router /api/admin/runtime/billing-settlements/{settlementId}/retry [post]
|
||||
func (s *Server) retryBillingSettlement(w http.ResponseWriter, r *http.Request) {
|
||||
key, ok := singleIdempotencyKey(r)
|
||||
if !ok {
|
||||
writeError(w, http.StatusBadRequest, "a single Idempotency-Key is required", "idempotency_key_required")
|
||||
return
|
||||
}
|
||||
keyHash := sha256.Sum256([]byte(key))
|
||||
settlementID := strings.TrimSpace(r.PathValue("settlementId"))
|
||||
if _, err := uuid.Parse(settlementID); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid billing settlement ID", "invalid_request")
|
||||
return
|
||||
}
|
||||
actor, _ := auth.UserFromContext(r.Context())
|
||||
var item store.BillingSettlement
|
||||
var audit store.AuditLog
|
||||
var replayed bool
|
||||
err := s.store.InTx(r.Context(), func(tx store.Tx) error {
|
||||
var err error
|
||||
item, replayed, err = s.store.RetryBillingSettlementTx(r.Context(), tx, settlementID, hex.EncodeToString(keyHash[:]))
|
||||
if err != nil || replayed {
|
||||
return err
|
||||
}
|
||||
audit, err = s.store.RecordAuditLogTx(r.Context(), tx, billingSettlementRetryAuditInput(r, actor, item))
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
switch {
|
||||
case store.IsNotFound(err):
|
||||
writeError(w, http.StatusNotFound, "billing settlement not found", "billing_settlement_not_found")
|
||||
case errors.Is(err, store.ErrBillingSettlementNotRetryable):
|
||||
writeError(w, http.StatusConflict, "billing settlement is not retryable", "billing_settlement_not_retryable")
|
||||
default:
|
||||
s.logger.Error("retry billing settlement failed", "settlementID", settlementID, "error_category", "billing_settlement_retry_failed")
|
||||
writeError(w, http.StatusInternalServerError, "retry billing settlement failed", "billing_settlement_retry_failed")
|
||||
}
|
||||
return
|
||||
}
|
||||
if replayed {
|
||||
w.Header().Set("Idempotent-Replayed", "true")
|
||||
}
|
||||
response := map[string]any{"settlement": item}
|
||||
if !replayed {
|
||||
response["auditLog"] = audit
|
||||
}
|
||||
writeJSON(w, http.StatusOK, response)
|
||||
}
|
||||
|
||||
func singleIdempotencyKey(r *http.Request) (string, bool) {
|
||||
values := r.Header.Values("Idempotency-Key")
|
||||
if len(values) != 1 {
|
||||
return "", false
|
||||
}
|
||||
value := strings.TrimSpace(values[0])
|
||||
if value == "" || len(value) > 255 || strings.Contains(value, ",") {
|
||||
return "", false
|
||||
}
|
||||
return value, true
|
||||
}
|
||||
|
||||
func billingSettlementRetryAuditInput(r *http.Request, actor *auth.User, item store.BillingSettlement) store.AuditLogInput {
|
||||
input := store.AuditLogInput{
|
||||
Category: "billing", Action: "billing.settlement.retry",
|
||||
TargetType: "billing_settlement", TargetID: item.ID,
|
||||
RequestIP: requestIP(r), UserAgent: r.UserAgent(),
|
||||
AfterState: map[string]any{"status": item.Status, "attempts": item.Attempts},
|
||||
Metadata: map[string]any{"taskId": item.TaskID, "action": item.Action, "currency": item.Currency},
|
||||
}
|
||||
if actor != nil {
|
||||
input.ActorGatewayUserID = uuidText(firstNonEmptyText(actor.GatewayUserID, actor.ID))
|
||||
input.ActorUserID = actor.ID
|
||||
input.ActorUsername = actor.Username
|
||||
input.ActorSource = actor.Source
|
||||
input.ActorRoles = actor.Roles
|
||||
}
|
||||
return input
|
||||
}
|
||||
@ -1,21 +0,0 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSingleIdempotencyKey(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
request := httptest.NewRequest("POST", "/", nil)
|
||||
request.Header.Add("Idempotency-Key", "retry-1")
|
||||
if got, ok := singleIdempotencyKey(request); !ok || got != "retry-1" {
|
||||
t.Fatalf("got key=%q ok=%v", got, ok)
|
||||
}
|
||||
|
||||
request.Header.Add("Idempotency-Key", "retry-2")
|
||||
if _, ok := singleIdempotencyKey(request); ok {
|
||||
t.Fatal("multiple Idempotency-Key values must be rejected")
|
||||
}
|
||||
}
|
||||
@ -48,7 +48,6 @@ func TestCoreLocalFlow(t *testing.T) {
|
||||
DatabaseURL: databaseURL,
|
||||
IdentityMode: "hybrid",
|
||||
JWTSecret: "test-secret",
|
||||
BillingEngineMode: "enforce",
|
||||
TaskProgressCallbackEnabled: true,
|
||||
TaskProgressCallbackURL: "http://callback.local/task-progress",
|
||||
CORSAllowedOrigin: "*",
|
||||
@ -445,6 +444,7 @@ VALUES ($1, 5, '{"purpose":"core-flow"}'::jsonb)`, inviteCode); err != nil {
|
||||
t.Fatalf("unexpected compatible chat response: %+v", compatChat)
|
||||
}
|
||||
|
||||
cancelMarker := "cancel-stream-" + suffixText
|
||||
cancelCtx, cancelRequest := context.WithCancel(context.Background())
|
||||
cancelPayload := map[string]any{
|
||||
"model": defaultTextModel,
|
||||
@ -453,6 +453,7 @@ VALUES ($1, 5, '{"purpose":"core-flow"}'::jsonb)`, inviteCode); err != nil {
|
||||
"stream": true,
|
||||
"simulation": true,
|
||||
"simulationDurationMs": 250,
|
||||
"cancelTestId": cancelMarker,
|
||||
}
|
||||
cancelRaw, err := json.Marshal(cancelPayload)
|
||||
if err != nil {
|
||||
@ -465,27 +466,15 @@ VALUES ($1, 5, '{"purpose":"core-flow"}'::jsonb)`, inviteCode); err != nil {
|
||||
cancelReq.Header.Set("Authorization", "Bearer "+apiKeyResponse.Secret)
|
||||
cancelReq.Header.Set("Content-Type", "application/json")
|
||||
cancelErrCh := make(chan error, 1)
|
||||
cancelTaskIDCh := make(chan string, 1)
|
||||
go func() {
|
||||
resp, err := http.DefaultClient.Do(cancelReq)
|
||||
if resp != nil {
|
||||
cancelTaskIDCh <- strings.TrimSpace(resp.Header.Get("X-Gateway-Task-Id"))
|
||||
_, _ = io.ReadAll(resp.Body)
|
||||
_ = resp.Body.Close()
|
||||
} else {
|
||||
cancelTaskIDCh <- ""
|
||||
}
|
||||
cancelErrCh <- err
|
||||
}()
|
||||
var cancelTaskID string
|
||||
select {
|
||||
case cancelTaskID = <-cancelTaskIDCh:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("cancelled stream did not return response headers")
|
||||
}
|
||||
if cancelTaskID == "" {
|
||||
t.Fatal("cancelled stream response did not expose X-Gateway-Task-Id")
|
||||
}
|
||||
cancelTaskID := waitForTaskIDByRequestMarker(t, ctx, testPool, cancelMarker, 2*time.Second)
|
||||
cancelRequest()
|
||||
select {
|
||||
case <-cancelErrCh:
|
||||
@ -815,7 +804,6 @@ LIMIT 1`).Scan(&gptImageModelTypesRaw); err != nil {
|
||||
"currency": "resource",
|
||||
"rules": []map[string]any{
|
||||
{"ruleKey": "text_input", "displayName": "Text Input", "resourceType": "text_input", "unit": "1k_tokens", "basePrice": 1},
|
||||
{"ruleKey": "text_cached_input", "displayName": "Text Cached Input", "resourceType": "text_cached_input", "unit": "1k_tokens", "basePrice": 0.5},
|
||||
{"ruleKey": "text_output", "displayName": "Text Output", "resourceType": "text_output", "unit": "1k_tokens", "basePrice": 2},
|
||||
{"ruleKey": "image", "displayName": "Image", "resourceType": "image", "unit": "image", "basePrice": 7},
|
||||
{"ruleKey": "image_edit", "displayName": "Image Edit", "resourceType": "image_edit", "unit": "image", "basePrice": 11},
|
||||
@ -847,101 +835,6 @@ WHERE gateway_user_id = $1::uuid
|
||||
AND currency = 'resource'`, smokeGatewayUserID).Scan(&walletBalanceBefore); err != nil {
|
||||
t.Fatalf("read wallet balance before pricing task: %v", err)
|
||||
}
|
||||
var estimateSideEffectsBefore struct {
|
||||
Tasks int
|
||||
Transactions int
|
||||
Frozen string
|
||||
}
|
||||
if err := testPool.QueryRow(ctx, `
|
||||
SELECT (SELECT count(*) FROM gateway_tasks),
|
||||
(SELECT count(*) FROM gateway_wallet_transactions WHERE gateway_user_id=$1::uuid),
|
||||
(SELECT frozen_balance::text FROM gateway_wallet_accounts WHERE gateway_user_id=$1::uuid AND currency='resource')`,
|
||||
smokeGatewayUserID,
|
||||
).Scan(&estimateSideEffectsBefore.Tasks, &estimateSideEffectsBefore.Transactions, &estimateSideEffectsBefore.Frozen); err != nil {
|
||||
t.Fatalf("read estimate side effects before request: %v", err)
|
||||
}
|
||||
var pricingEstimate struct {
|
||||
TotalAmount float64 `json:"totalAmount"`
|
||||
ReservationAmount float64 `json:"reservationAmount"`
|
||||
CandidateCount int `json:"candidateCount"`
|
||||
PricingVersion string `json:"pricingVersion"`
|
||||
RequestFingerprint string `json:"requestFingerprint"`
|
||||
}
|
||||
doJSON(t, server.URL, http.MethodPost, "/api/v1/pricing/estimate", apiKeyResponse.Secret, map[string]any{
|
||||
"kind": "chat.completions", "model": pricingModel, "max_completion_tokens": 32,
|
||||
"messages": []map[string]any{{"role": "user", "content": "estimate only"}},
|
||||
}, http.StatusOK, &pricingEstimate)
|
||||
if pricingEstimate.TotalAmount <= 0 || pricingEstimate.ReservationAmount < pricingEstimate.TotalAmount ||
|
||||
pricingEstimate.CandidateCount != 1 || pricingEstimate.PricingVersion != "effective-pricing-v2" || pricingEstimate.RequestFingerprint == "" {
|
||||
t.Fatalf("unexpected effective pricing estimate: %+v", pricingEstimate)
|
||||
}
|
||||
var estimateSideEffectsAfter struct {
|
||||
Tasks int
|
||||
Transactions int
|
||||
Frozen string
|
||||
}
|
||||
if err := testPool.QueryRow(ctx, `
|
||||
SELECT (SELECT count(*) FROM gateway_tasks),
|
||||
(SELECT count(*) FROM gateway_wallet_transactions WHERE gateway_user_id=$1::uuid),
|
||||
(SELECT frozen_balance::text FROM gateway_wallet_accounts WHERE gateway_user_id=$1::uuid AND currency='resource')`,
|
||||
smokeGatewayUserID,
|
||||
).Scan(&estimateSideEffectsAfter.Tasks, &estimateSideEffectsAfter.Transactions, &estimateSideEffectsAfter.Frozen); err != nil {
|
||||
t.Fatalf("read estimate side effects after request: %v", err)
|
||||
}
|
||||
if estimateSideEffectsAfter != estimateSideEffectsBefore {
|
||||
t.Fatalf("pricing estimate must be read-only, before=%+v after=%+v", estimateSideEffectsBefore, estimateSideEffectsAfter)
|
||||
}
|
||||
|
||||
idempotencyPayload := map[string]any{
|
||||
"model": pricingModel, "runMode": "simulation", "simulation": true, "simulationDurationMs": 5,
|
||||
"messages": []map[string]any{{"role": "user", "content": "idempotent replay"}},
|
||||
}
|
||||
idempotencyHeaders := map[string]string{"Idempotency-Key": "chat-replay-" + suffixText}
|
||||
var idempotentFirst map[string]any
|
||||
firstReplayHeaders := doJSONWithHeaders(t, server.URL, http.MethodPost, "/api/v1/chat/completions", apiKeyResponse.Secret, idempotencyPayload, idempotencyHeaders, http.StatusOK, &idempotentFirst)
|
||||
var idempotentSecond map[string]any
|
||||
secondReplayHeaders := doJSONWithHeaders(t, server.URL, http.MethodPost, "/api/v1/chat/completions", apiKeyResponse.Secret, idempotencyPayload, idempotencyHeaders, http.StatusOK, &idempotentSecond)
|
||||
idempotentTaskID := firstReplayHeaders.Get("X-Gateway-Task-Id")
|
||||
if idempotentTaskID == "" || secondReplayHeaders.Get("X-Gateway-Task-Id") != idempotentTaskID || secondReplayHeaders.Get("Idempotent-Replayed") != "true" {
|
||||
t.Fatalf("non-stream replay headers first=%v second=%v", firstReplayHeaders, secondReplayHeaders)
|
||||
}
|
||||
var idempotentAttempts int
|
||||
if err := testPool.QueryRow(ctx, `SELECT count(*) FROM gateway_task_attempts WHERE task_id=$1::uuid`, idempotentTaskID).Scan(&idempotentAttempts); err != nil {
|
||||
t.Fatalf("count idempotent task attempts: %v", err)
|
||||
}
|
||||
if idempotentAttempts != 1 {
|
||||
t.Fatalf("idempotent request called upstream %d times, want 1", idempotentAttempts)
|
||||
}
|
||||
idempotencyPayload["messages"] = []map[string]any{{"role": "user", "content": "different request"}}
|
||||
doJSONWithHeaders(t, server.URL, http.MethodPost, "/api/v1/chat/completions", apiKeyResponse.Secret, idempotencyPayload, idempotencyHeaders, http.StatusConflict, nil)
|
||||
|
||||
streamPayload := map[string]any{
|
||||
"model": pricingModel, "runMode": "simulation", "simulation": true, "simulationDurationMs": 5, "stream": true,
|
||||
"messages": []map[string]any{{"role": "user", "content": "stream replay"}},
|
||||
}
|
||||
streamRaw, err := json.Marshal(streamPayload)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
streamKey := "stream-replay-" + suffixText
|
||||
streamRequest, err := http.NewRequest(http.MethodPost, server.URL+"/api/v1/chat/completions", bytes.NewReader(streamRaw))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
streamRequest.Header.Set("Authorization", "Bearer "+apiKeyResponse.Secret)
|
||||
streamRequest.Header.Set("Content-Type", "application/json")
|
||||
streamRequest.Header.Set("Idempotency-Key", streamKey)
|
||||
streamResponse, err := http.DefaultClient.Do(streamRequest)
|
||||
if err != nil {
|
||||
t.Fatalf("execute first idempotent stream: %v", err)
|
||||
}
|
||||
_, _ = io.ReadAll(streamResponse.Body)
|
||||
_ = streamResponse.Body.Close()
|
||||
if streamResponse.StatusCode != http.StatusOK || streamResponse.Header.Get("X-Gateway-Task-Id") == "" {
|
||||
t.Fatalf("first idempotent stream status=%d headers=%v", streamResponse.StatusCode, streamResponse.Header)
|
||||
}
|
||||
doJSONWithHeaders(t, server.URL, http.MethodPost, "/api/v1/chat/completions", apiKeyResponse.Secret, streamPayload,
|
||||
map[string]string{"Idempotency-Key": streamKey}, http.StatusConflict, nil)
|
||||
var pricingTask struct {
|
||||
Task struct {
|
||||
ID string `json:"id"`
|
||||
@ -952,7 +845,7 @@ SELECT (SELECT count(*) FROM gateway_tasks),
|
||||
}
|
||||
doAPIV1ChatCompletionAndLoadTask(t, ctx, testPool, server.URL, apiKeyResponse.Secret, map[string]any{
|
||||
"model": pricingModel,
|
||||
"runMode": "production",
|
||||
"runMode": "simulation",
|
||||
"simulation": true,
|
||||
"simulationDurationMs": 5,
|
||||
"messages": []map[string]any{{"role": "user", "content": "priced ping"}},
|
||||
@ -960,20 +853,6 @@ SELECT (SELECT count(*) FROM gateway_tasks),
|
||||
if pricingTask.Task.Status != "succeeded" || !floatNear(pricingTask.Task.FinalChargeAmount, 0.028) {
|
||||
t.Fatalf("custom pricing rule set should drive text billing, got task=%+v", pricingTask.Task)
|
||||
}
|
||||
settlementDeadline := time.Now().Add(3 * time.Second)
|
||||
for {
|
||||
var billingStatus string
|
||||
if err := testPool.QueryRow(ctx, `SELECT billing_status FROM gateway_tasks WHERE id = $1::uuid`, pricingTask.Task.ID).Scan(&billingStatus); err != nil {
|
||||
t.Fatalf("read pricing task billing status: %v", err)
|
||||
}
|
||||
if billingStatus == "settled" {
|
||||
break
|
||||
}
|
||||
if time.Now().After(settlementDeadline) {
|
||||
t.Fatalf("pricing task billing did not settle before deadline, status=%s", billingStatus)
|
||||
}
|
||||
time.Sleep(25 * time.Millisecond)
|
||||
}
|
||||
var walletBalanceAfter float64
|
||||
var walletSpentAfter float64
|
||||
if err := testPool.QueryRow(ctx, `
|
||||
@ -1605,7 +1484,7 @@ WHERE m.platform_id = $1::uuid
|
||||
t.Fatalf("workspace task list should include persisted task records, got %+v", workspaceTaskList.Items)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, server.URL+"/api/v1/tasks/"+pricingTask.Task.ID+"/events", nil)
|
||||
req, err := http.NewRequest(http.MethodGet, server.URL+"/api/v1/tasks/"+taskResponse.Task.ID+"/events", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("build events request: %v", err)
|
||||
}
|
||||
@ -1685,7 +1564,6 @@ WHERE m.platform_id = $1::uuid
|
||||
DatabaseURL: databaseURL,
|
||||
IdentityMode: "hybrid",
|
||||
JWTSecret: "test-secret",
|
||||
BillingEngineMode: "enforce",
|
||||
TaskProgressCallbackEnabled: true,
|
||||
TaskProgressCallbackURL: "http://callback.local/task-progress",
|
||||
CORSAllowedOrigin: "*",
|
||||
@ -1986,7 +1864,7 @@ func doJSON(t *testing.T, baseURL string, method string, path string, token stri
|
||||
doJSONWithHeaders(t, baseURL, method, path, token, payload, nil, expectedStatus, out)
|
||||
}
|
||||
|
||||
func doJSONWithHeaders(t *testing.T, baseURL string, method string, path string, token string, payload any, headers map[string]string, expectedStatus int, out any) http.Header {
|
||||
func doJSONWithHeaders(t *testing.T, baseURL string, method string, path string, token string, payload any, headers map[string]string, expectedStatus int, out any) {
|
||||
t.Helper()
|
||||
var body io.Reader
|
||||
if payload != nil {
|
||||
@ -2023,22 +1901,16 @@ func doJSONWithHeaders(t *testing.T, baseURL string, method string, path string,
|
||||
t.Fatalf("decode %s %s response: %v body=%s", method, path, err, string(raw))
|
||||
}
|
||||
}
|
||||
return resp.Header.Clone()
|
||||
}
|
||||
|
||||
func doAPIV1ChatCompletionAndLoadTask(t *testing.T, ctx context.Context, pool *pgxpool.Pool, baseURL string, token string, payload map[string]any, marker string, expectedStatus int, responseOut any, taskDetailOut any) string {
|
||||
t.Helper()
|
||||
_ = ctx
|
||||
_ = pool
|
||||
_ = marker
|
||||
payload["integrationTestMarker"] = marker
|
||||
if responseOut == nil {
|
||||
responseOut = &map[string]any{}
|
||||
}
|
||||
responseHeaders := doJSONWithHeaders(t, baseURL, http.MethodPost, "/api/v1/chat/completions", token, payload, nil, expectedStatus, responseOut)
|
||||
taskID := strings.TrimSpace(responseHeaders.Get("X-Gateway-Task-Id"))
|
||||
if taskID == "" {
|
||||
t.Fatal("chat completion response did not expose X-Gateway-Task-Id")
|
||||
}
|
||||
doJSON(t, baseURL, http.MethodPost, "/api/v1/chat/completions", token, payload, expectedStatus, responseOut)
|
||||
taskID := waitForTaskIDByRequestField(t, ctx, pool, "integrationTestMarker", marker, 2*time.Second)
|
||||
if taskDetailOut != nil {
|
||||
doJSON(t, baseURL, http.MethodGet, "/api/v1/tasks/"+taskID, token, nil, http.StatusOK, taskDetailOut)
|
||||
}
|
||||
@ -2174,6 +2046,11 @@ func waitForTaskStatus(t *testing.T, baseURL string, token string, taskID string
|
||||
return detail
|
||||
}
|
||||
|
||||
func waitForTaskIDByRequestMarker(t *testing.T, ctx context.Context, pool *pgxpool.Pool, marker string, timeout time.Duration) string {
|
||||
t.Helper()
|
||||
return waitForTaskIDByRequestField(t, ctx, pool, "cancelTestId", marker, timeout)
|
||||
}
|
||||
|
||||
func waitForTaskIDByRequestField(t *testing.T, ctx context.Context, pool *pgxpool.Pool, key string, value string, timeout time.Duration) string {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(timeout)
|
||||
|
||||
@ -4,7 +4,6 @@ import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
@ -95,11 +94,6 @@ func (s *Server) geminiGenerateContent(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusForbidden, "api key scope does not allow this capability")
|
||||
return
|
||||
}
|
||||
idempotencyKey, hasIdempotencyKey, err := optionalTaskIdempotencyKey(r)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Idempotency-Key must contain one non-empty value", "invalid_idempotency_key")
|
||||
return
|
||||
}
|
||||
prepared, err := s.prepareTaskRequest(r.Context(), r, user, mapping.Body)
|
||||
if err != nil {
|
||||
s.logger.Warn("prepare gemini task request failed", "kind", mapping.Kind, "error", err)
|
||||
@ -110,7 +104,7 @@ func (s *Server) geminiGenerateContent(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, status, err.Error(), clients.ErrorCode(err))
|
||||
return
|
||||
}
|
||||
createInput := store.CreateTaskInput{
|
||||
task, err := s.store.CreateTask(r.Context(), store.CreateTaskInput{
|
||||
Kind: mapping.Kind,
|
||||
Model: mapping.Model,
|
||||
RunMode: runModeFromRequest(prepared.Body),
|
||||
@ -119,33 +113,10 @@ func (s *Server) geminiGenerateContent(w http.ResponseWriter, r *http.Request) {
|
||||
ConversationID: prepared.ConversationID,
|
||||
NewMessageCount: prepared.NewMessageCount,
|
||||
MessageRefs: prepared.MessageRefs,
|
||||
}
|
||||
if hasIdempotencyKey {
|
||||
createInput.IdempotencyKeyHash = taskIdempotencyKeyHash(idempotencyKey)
|
||||
createInput.IdempotencyRequestHash = taskIdempotencyRequestHash(mapping.Kind, false, false, prepared.Body)
|
||||
}
|
||||
created, err := s.store.CreateTaskIdempotent(r.Context(), createInput, user)
|
||||
}, user)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrIdempotencyKeyReused) {
|
||||
writeError(w, http.StatusConflict, "Idempotency-Key was reused for a different request", "idempotency_key_reused")
|
||||
return
|
||||
}
|
||||
s.logger.Error("create gemini task failed", "kind", mapping.Kind, "error_category", "task_create_failed")
|
||||
writeError(w, http.StatusInternalServerError, "create task failed", "task_create_failed")
|
||||
return
|
||||
}
|
||||
task := created.Task
|
||||
if created.Replayed {
|
||||
if s.billingMetrics != nil {
|
||||
s.billingMetrics.ObserveBillingEvent("idempotent_replay")
|
||||
}
|
||||
w.Header().Set("Idempotent-Replayed", "true")
|
||||
w.Header().Set("X-Gateway-Task-Id", task.ID)
|
||||
if task.Status == "succeeded" {
|
||||
writeJSON(w, http.StatusOK, geminiGenerateContentResponse(task.Result, mapping.Model))
|
||||
return
|
||||
}
|
||||
writeIdempotentTaskReplay(w, task, true)
|
||||
s.logger.Error("create gemini task failed", "kind", mapping.Kind, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "create task failed")
|
||||
return
|
||||
}
|
||||
runCtx, cancelRun := s.requestExecutionContext(r)
|
||||
|
||||
@ -892,7 +892,6 @@ func (s *Server) deleteAPIKey(w http.ResponseWriter, r *http.Request) {
|
||||
// @Failure 403 {object} ErrorEnvelope
|
||||
// @Failure 404 {object} ErrorEnvelope
|
||||
// @Failure 429 {object} ErrorEnvelope
|
||||
// @Failure 503 {object} ErrorEnvelope
|
||||
// @Failure 500 {object} ErrorEnvelope
|
||||
// @Router /api/v1/pricing/estimate [post]
|
||||
func (s *Server) estimatePricing(w http.ResponseWriter, r *http.Request) {
|
||||
@ -917,24 +916,10 @@ func (s *Server) estimatePricing(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
estimate, err := s.runner.Estimate(r.Context(), kind, model, body, user)
|
||||
if err != nil {
|
||||
if s.billingMetrics != nil {
|
||||
s.billingMetrics.ObserveBillingEvent("estimate_failed")
|
||||
}
|
||||
if runner.IsPricingUnavailable(err) {
|
||||
if s.billingMetrics != nil {
|
||||
s.billingMetrics.ObserveBillingEvent("pricing_unavailable")
|
||||
}
|
||||
writeErrorWithDetails(w, http.StatusServiceUnavailable, runErrorMessage(err), runErrorDetails(err), "pricing_unavailable")
|
||||
return
|
||||
}
|
||||
if errors.Is(err, store.ErrNoModelCandidate) {
|
||||
writeErrorWithDetails(w, statusFromRunError(err), runErrorMessage(err), runErrorDetails(err), store.ModelCandidateErrorCode(err))
|
||||
return
|
||||
}
|
||||
if code := clients.ErrorCode(err); code == "bad_request" || code == "invalid_parameter" {
|
||||
writeErrorWithDetails(w, http.StatusBadRequest, runErrorMessage(err), runErrorDetails(err), code)
|
||||
return
|
||||
}
|
||||
s.logger.Error("estimate pricing failed", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "estimate pricing failed")
|
||||
return
|
||||
@ -992,7 +977,6 @@ func (s *Server) listModelRateLimitStatuses(w http.ResponseWriter, r *http.Reque
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param X-Async header bool false "true 时异步创建任务并返回 202"
|
||||
// @Param Idempotency-Key header string false "可选请求幂等键;同一用户范围内唯一"
|
||||
// @Param input body TaskRequest true "AI 任务请求,字段随任务类型变化"
|
||||
// @Success 200 {object} CompatibleResponse
|
||||
// @Success 202 {object} TaskAcceptedResponse
|
||||
@ -1061,11 +1045,6 @@ func (s *Server) createTask(kind string, compatible bool) http.Handler {
|
||||
return
|
||||
}
|
||||
responsePlan := planTaskResponse(kind, compatible, body, r)
|
||||
idempotencyKey, hasIdempotencyKey, err := optionalTaskIdempotencyKey(r)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Idempotency-Key must contain one non-empty value", "invalid_idempotency_key")
|
||||
return
|
||||
}
|
||||
prepared, err := s.prepareTaskRequest(r.Context(), r, user, body)
|
||||
if err != nil {
|
||||
s.logger.Warn("prepare task request failed", "kind", kind, "error", err)
|
||||
@ -1077,7 +1056,7 @@ func (s *Server) createTask(kind string, compatible bool) http.Handler {
|
||||
return
|
||||
}
|
||||
|
||||
createInput := store.CreateTaskInput{
|
||||
task, err := s.store.CreateTask(r.Context(), store.CreateTaskInput{
|
||||
Kind: kind,
|
||||
Model: model,
|
||||
RunMode: runModeFromRequest(prepared.Body),
|
||||
@ -1086,32 +1065,10 @@ func (s *Server) createTask(kind string, compatible bool) http.Handler {
|
||||
ConversationID: prepared.ConversationID,
|
||||
NewMessageCount: prepared.NewMessageCount,
|
||||
MessageRefs: prepared.MessageRefs,
|
||||
}
|
||||
if hasIdempotencyKey {
|
||||
createInput.IdempotencyKeyHash = taskIdempotencyKeyHash(idempotencyKey)
|
||||
createInput.IdempotencyRequestHash = taskIdempotencyRequestHash(kind, responsePlan.asyncMode, responsePlan.streamMode, prepared.Body)
|
||||
}
|
||||
created, err := s.store.CreateTaskIdempotent(r.Context(), createInput, user)
|
||||
}, user)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrIdempotencyKeyReused) {
|
||||
writeError(w, http.StatusConflict, "Idempotency-Key was reused for a different request", "idempotency_key_reused")
|
||||
return
|
||||
}
|
||||
s.logger.Error("create task failed", "kind", kind, "error_category", "task_create_failed")
|
||||
writeError(w, http.StatusInternalServerError, "create task failed", "task_create_failed")
|
||||
return
|
||||
}
|
||||
task := created.Task
|
||||
if created.Replayed {
|
||||
if s.billingMetrics != nil {
|
||||
s.billingMetrics.ObserveBillingEvent("idempotent_replay")
|
||||
}
|
||||
w.Header().Set("Idempotent-Replayed", "true")
|
||||
if responsePlan.streamMode {
|
||||
writeError(w, http.StatusConflict, "streaming idempotent replay is not supported", "idempotency_stream_replay_unsupported")
|
||||
return
|
||||
}
|
||||
writeIdempotentTaskReplay(w, task, responsePlan.compatibleMode)
|
||||
s.logger.Error("create task failed", "kind", kind, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "create task failed")
|
||||
return
|
||||
}
|
||||
if responsePlan.asyncMode {
|
||||
@ -1426,10 +1383,6 @@ func scopeForTaskKind(kind string) string {
|
||||
|
||||
func statusFromRunError(err error) int {
|
||||
switch {
|
||||
case clients.ErrorCode(err) == "billing_hold":
|
||||
return http.StatusServiceUnavailable
|
||||
case runner.IsPricingUnavailable(err):
|
||||
return http.StatusServiceUnavailable
|
||||
case clients.ErrorCode(err) == "invalid_previous_response_id" || clients.ErrorCode(err) == "response_chain_too_deep" || clients.ErrorCode(err) == "unsupported_model_protocol" || clients.ErrorCode(err) == "unsupported_response_tool" || clients.ErrorCode(err) == "unsupported_response_parameter":
|
||||
return http.StatusBadRequest
|
||||
case clients.ErrorCode(err) == "response_chain_unavailable":
|
||||
@ -1458,9 +1411,6 @@ func statusFromRunError(err error) int {
|
||||
}
|
||||
|
||||
func runErrorCode(err error) string {
|
||||
if runner.IsPricingUnavailable(err) {
|
||||
return "pricing_unavailable"
|
||||
}
|
||||
if errors.Is(err, store.ErrNoModelCandidate) {
|
||||
return store.ModelCandidateErrorCode(err)
|
||||
}
|
||||
@ -1478,9 +1428,6 @@ func runErrorMessage(err error) string {
|
||||
}
|
||||
|
||||
func runErrorDetails(err error) map[string]any {
|
||||
if detail := runner.PricingUnavailableDetails(err); len(detail) > 0 {
|
||||
return map[string]any{"pricing": detail}
|
||||
}
|
||||
if detail := rateLimitErrorDetail(err); len(detail) > 0 {
|
||||
return map[string]any{"rateLimit": detail}
|
||||
}
|
||||
|
||||
@ -43,7 +43,7 @@ func TestOIDCJITFullGatewayUserFlowAndSecurityBoundary(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("connect store: %v", err)
|
||||
}
|
||||
t.Cleanup(db.Close)
|
||||
defer db.Close()
|
||||
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
@ -247,9 +247,6 @@ DELETE FROM gateway_users WHERE source = 'oidc' AND external_user_id = ANY($1::t
|
||||
t.Fatalf("rejected OIDC tokens created %d local users", rejectedWrites)
|
||||
}
|
||||
|
||||
if _, err := db.DisableActiveIdentityRevision(ctx, activeRevision.Version, "oidc-jit-disabled", "oidc-jit-disabled"); err != nil {
|
||||
t.Fatalf("disable initial OIDC JIT revision: %v", err)
|
||||
}
|
||||
disabledJITRevision := prepareOIDCJITRevision(t, ctx, db, baseConfig, issuer, "default", false)
|
||||
testRevisionIDs = append(testRevisionIDs, disabledJITRevision.ID)
|
||||
disabledJITRevision, _, err = db.ActivateIdentityRevision(ctx, disabledJITRevision.ID, disabledJITRevision.Version, "oidc-jit-disabled", "oidc-jit-disabled")
|
||||
@ -301,9 +298,6 @@ func prepareOIDCJITRevision(t *testing.T, ctx context.Context, db *store.Store,
|
||||
if err := secrets.Put(ctx, sessionReference, bytes.Repeat([]byte{8}, 32)); err != nil {
|
||||
t.Fatalf("store OIDC JIT session key: %v", err)
|
||||
}
|
||||
if err := db.QueueIdentitySecretCleanup(ctx, sessionReference, time.Now().Add(10*time.Minute)); err != nil {
|
||||
t.Fatalf("stage OIDC JIT session key for adoption: %v", err)
|
||||
}
|
||||
draft, err = db.ApplyIdentityManifest(ctx, draft.ID, draft.Version, identity.ManifestApplication{
|
||||
Manifest: identity.ManifestV1{
|
||||
SchemaVersion: 1, Issuer: issuer, TenantID: oidcJITTenantID, ApplicationID: uuid.NewString(),
|
||||
@ -366,10 +360,6 @@ var currentOIDCTestNonce string
|
||||
|
||||
func createOIDCBFFSessionCookie(t *testing.T, baseURL string) *http.Cookie {
|
||||
t.Helper()
|
||||
parsedBaseURL, err := url.Parse(baseURL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
jar, err := cookiejar.New(nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@ -381,11 +371,6 @@ func createOIDCBFFSessionCookie(t *testing.T, baseURL string) *http.Cookie {
|
||||
if len(via) > 10 {
|
||||
return errors.New("too many redirects")
|
||||
}
|
||||
// Follow the synthetic issuer's authorization redirect, then stop before
|
||||
// the callback redirects the browser to the separately hosted web app.
|
||||
if request.URL.Host != parsedBaseURL.Host && request.URL.Path != "/authorize" {
|
||||
return http.ErrUseLastResponse
|
||||
}
|
||||
return nil
|
||||
}}
|
||||
response, err := client.Get(baseURL + "/api/v1/auth/oidc/login?returnTo=%2Fapi%2Fv1%2Fme")
|
||||
@ -393,10 +378,11 @@ func createOIDCBFFSessionCookie(t *testing.T, baseURL string) *http.Cookie {
|
||||
t.Fatalf("complete OIDC BFF login: %v", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode != http.StatusOK && response.StatusCode != http.StatusSeeOther {
|
||||
if response.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(response.Body)
|
||||
t.Fatalf("OIDC BFF login status = %d, want 200: %s", response.StatusCode, body)
|
||||
}
|
||||
parsedBaseURL, _ := url.Parse(baseURL)
|
||||
for _, cookie := range jar.Cookies(parsedBaseURL) {
|
||||
if cookie.Name == auth.OIDCSessionCookieName {
|
||||
if strings.Count(cookie.Value, ".") == 2 {
|
||||
|
||||
@ -114,18 +114,6 @@ type AuditLogListResponse struct {
|
||||
Items []store.AuditLog `json:"items"`
|
||||
}
|
||||
|
||||
type BillingSettlementListResponse struct {
|
||||
Items []store.BillingSettlement `json:"items"`
|
||||
Total int `json:"total" example:"42"`
|
||||
Page int `json:"page" example:"1"`
|
||||
PageSize int `json:"pageSize" example:"50"`
|
||||
}
|
||||
|
||||
type BillingSettlementRetryResponse struct {
|
||||
Settlement store.BillingSettlement `json:"settlement"`
|
||||
AuditLog store.AuditLog `json:"auditLog"`
|
||||
}
|
||||
|
||||
type WalletTransactionListResponse struct {
|
||||
Items []store.GatewayWalletTransaction `json:"items"`
|
||||
Total int `json:"total" example:"42"`
|
||||
@ -200,14 +188,8 @@ type PricingEstimateRequest struct {
|
||||
}
|
||||
|
||||
type PricingEstimateResponse struct {
|
||||
Items []map[string]interface{} `json:"items"`
|
||||
Resolver string `json:"resolver" example:"effective-pricing-v2"`
|
||||
TotalAmount float64 `json:"totalAmount" example:"1.25"`
|
||||
ReservationAmount float64 `json:"reservationAmount" example:"2.75"`
|
||||
Currency string `json:"currency" example:"resource"`
|
||||
CandidateCount int `json:"candidateCount" example:"2"`
|
||||
PricingVersion string `json:"pricingVersion" example:"effective-pricing-v2"`
|
||||
RequestFingerprint string `json:"requestFingerprint" example:"76ef6a537de8e71bd1ca93acadc078dbdbfa9f17e45224e4f9df59f535d2886f"`
|
||||
Items []map[string]interface{} `json:"items"`
|
||||
Resolver string `json:"resolver" example:"effective-pricing-v1"`
|
||||
}
|
||||
|
||||
type TaskRequest struct {
|
||||
|
||||
@ -1,27 +0,0 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/runner"
|
||||
)
|
||||
|
||||
func TestPricingUnavailableUsesStructuredServiceUnavailableError(t *testing.T) {
|
||||
err := &runner.PricingUnavailableError{
|
||||
Reason: "missing, invalid, or not explicitly free",
|
||||
ResourceType: "image",
|
||||
RuleSetID: "rule-set-id",
|
||||
}
|
||||
if got := statusFromRunError(err); got != http.StatusServiceUnavailable {
|
||||
t.Fatalf("statusFromRunError()=%d, want %d", got, http.StatusServiceUnavailable)
|
||||
}
|
||||
if got := runErrorCode(err); got != "pricing_unavailable" {
|
||||
t.Fatalf("runErrorCode()=%q, want pricing_unavailable", got)
|
||||
}
|
||||
details := runErrorDetails(err)
|
||||
pricing, _ := details["pricing"].(map[string]any)
|
||||
if pricing["resourceType"] != "image" || pricing["ruleSetId"] != "rule-set-id" {
|
||||
t.Fatalf("unexpected pricing details: %+v", details)
|
||||
}
|
||||
}
|
||||
@ -5,7 +5,6 @@ import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
||||
)
|
||||
@ -147,45 +146,10 @@ func validPricingRuleSetInput(input store.PricingRuleSetInput) bool {
|
||||
if strings.TrimSpace(input.RuleSetKey) == "" || strings.TrimSpace(input.Name) == "" || len(input.Rules) == 0 {
|
||||
return false
|
||||
}
|
||||
if currency := strings.TrimSpace(input.Currency); currency != "" && currency != "resource" {
|
||||
return false
|
||||
}
|
||||
for _, rule := range input.Rules {
|
||||
if strings.TrimSpace(rule.ResourceType) == "" || strings.TrimSpace(rule.Unit) == "" {
|
||||
return false
|
||||
}
|
||||
if rule.BasePrice < 0 || (rule.BasePrice == 0 && !rule.IsFree) {
|
||||
return false
|
||||
}
|
||||
if currency := strings.TrimSpace(rule.Currency); currency != "" && currency != "resource" {
|
||||
return false
|
||||
}
|
||||
switch calculator := strings.TrimSpace(rule.CalculatorType); calculator {
|
||||
case "", "token_usage", "unit_weight", "duration_weight":
|
||||
default:
|
||||
return false
|
||||
}
|
||||
calculator := strings.TrimSpace(rule.CalculatorType)
|
||||
if calculator == "" {
|
||||
calculator = store.DefaultEffectivePricingCalculator(rule.ResourceType)
|
||||
}
|
||||
if store.ValidateEffectivePricingRuleShape(strings.TrimSpace(rule.ResourceType), store.NormalizeEffectivePricingRuleUnit(rule.ResourceType, rule.Unit), calculator) != nil {
|
||||
return false
|
||||
}
|
||||
effectiveFrom, fromOK := pricingEffectiveTime(rule.EffectiveFrom)
|
||||
effectiveTo, toOK := pricingEffectiveTime(rule.EffectiveTo)
|
||||
if !fromOK || !toOK || (!effectiveFrom.IsZero() && !effectiveTo.IsZero() && !effectiveFrom.Before(effectiveTo)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func pricingEffectiveTime(value string) (time.Time, bool) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return time.Time{}, true
|
||||
}
|
||||
parsed, err := time.Parse(time.RFC3339, value)
|
||||
return parsed, err == nil
|
||||
}
|
||||
|
||||
@ -1,28 +0,0 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
||||
)
|
||||
|
||||
func TestPricingRuleSetRejectsImplicitZeroAndUnsupportedCalculator(t *testing.T) {
|
||||
base := store.PricingRuleSetInput{
|
||||
RuleSetKey: "test", Name: "test",
|
||||
Rules: []store.PricingRuleInput{{
|
||||
ResourceType: "image", Unit: "image", BasePrice: 0,
|
||||
Currency: "resource", CalculatorType: "unit_weight",
|
||||
}},
|
||||
}
|
||||
if validPricingRuleSetInput(base) {
|
||||
t.Fatal("implicit zero price must be rejected")
|
||||
}
|
||||
base.Rules[0].IsFree = true
|
||||
if !validPricingRuleSetInput(base) {
|
||||
t.Fatal("explicit free price should be accepted")
|
||||
}
|
||||
base.Rules[0].CalculatorType = "formula"
|
||||
if validPricingRuleSetInput(base) {
|
||||
t.Fatal("arbitrary formula calculator must be rejected")
|
||||
}
|
||||
}
|
||||
@ -43,7 +43,6 @@ type Server struct {
|
||||
identityTestRevision identity.Revision
|
||||
identityTestCookieSecure bool
|
||||
identityTestBrowserEnabled bool
|
||||
billingMetrics *ssfreceiver.Metrics
|
||||
}
|
||||
|
||||
type oidcPublicClient interface {
|
||||
@ -67,19 +66,18 @@ func NewServer(cfg config.Config, db *store.Store, logger *slog.Logger) http.Han
|
||||
}
|
||||
|
||||
func NewServerWithContext(ctx context.Context, cfg config.Config, db *store.Store, logger *slog.Logger) http.Handler {
|
||||
securityEventMetrics := &ssfreceiver.Metrics{}
|
||||
server := &Server{
|
||||
ctx: ctx,
|
||||
cfg: cfg,
|
||||
store: db,
|
||||
oidcUserResolver: db,
|
||||
auth: auth.New(cfg.JWTSecret, cfg.ServerMainBaseURL, cfg.ServerMainInternalToken),
|
||||
runner: runner.New(cfg, db, logger, securityEventMetrics),
|
||||
runner: runner.New(cfg, db, logger),
|
||||
logger: logger,
|
||||
billingMetrics: securityEventMetrics,
|
||||
}
|
||||
server.auth.ServerMainInternalKey = cfg.ServerMainInternalKey
|
||||
server.auth.ServerMainInternalSecret = cfg.ServerMainInternalSecret
|
||||
securityEventMetrics := &ssfreceiver.Metrics{}
|
||||
secretStore, err := identitySecretStore(cfg)
|
||||
if err != nil {
|
||||
panic("invalid identity SecretStore: " + err.Error())
|
||||
@ -120,7 +118,6 @@ func NewServerWithContext(ctx context.Context, cfg config.Config, db *store.Stor
|
||||
}
|
||||
server.auth.LocalAPIKeyVerifier = db.VerifyLocalAPIKey
|
||||
server.runner.StartAsyncQueueWorker(ctx)
|
||||
server.runner.StartBillingSettlementWorker(ctx)
|
||||
server.startLocalTempAssetCleanup(ctx)
|
||||
server.startOIDCSessionCleanup(ctx)
|
||||
|
||||
@ -207,8 +204,6 @@ func NewServerWithContext(ctx context.Context, cfg config.Config, db *store.Stor
|
||||
mux.Handle("DELETE /api/admin/runtime/policy-sets/{policySetID}", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.deleteRuntimePolicySet)))
|
||||
mux.Handle("GET /api/admin/runtime/runner-policy", server.requireAdmin(auth.PermissionPower, http.HandlerFunc(server.getRunnerPolicy)))
|
||||
mux.Handle("PATCH /api/admin/runtime/runner-policy", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.updateRunnerPolicy)))
|
||||
mux.Handle("GET /api/admin/runtime/billing-settlements", server.requireAdmin(auth.PermissionPower, http.HandlerFunc(server.listBillingSettlements)))
|
||||
mux.Handle("POST /api/admin/runtime/billing-settlements/{settlementId}/retry", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.retryBillingSettlement)))
|
||||
mux.Handle("POST /api/admin/runtime/model-rate-limits/{platformModelID}/restore", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.restorePlatformModelRuntimeStatus)))
|
||||
mux.Handle("GET /api/admin/config/network-proxy", server.requireAdmin(auth.PermissionPower, http.HandlerFunc(server.getNetworkProxyConfig)))
|
||||
mux.Handle("GET /api/admin/system/file-storage/settings", server.requireAdmin(auth.PermissionPower, http.HandlerFunc(server.getFileStorageSettings)))
|
||||
|
||||
@ -1,85 +0,0 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
||||
)
|
||||
|
||||
var errInvalidTaskIdempotencyKey = errors.New("invalid Idempotency-Key header")
|
||||
|
||||
func optionalTaskIdempotencyKey(r *http.Request) (string, bool, error) {
|
||||
values := r.Header.Values("Idempotency-Key")
|
||||
if len(values) == 0 {
|
||||
return "", false, nil
|
||||
}
|
||||
if len(values) != 1 {
|
||||
return "", false, errInvalidTaskIdempotencyKey
|
||||
}
|
||||
value := strings.TrimSpace(values[0])
|
||||
if value == "" || len(value) > 255 || strings.Contains(value, ",") {
|
||||
return "", false, errInvalidTaskIdempotencyKey
|
||||
}
|
||||
return value, true, nil
|
||||
}
|
||||
|
||||
func taskIdempotencyKeyHash(key string) string {
|
||||
digest := sha256.Sum256([]byte(key))
|
||||
return hex.EncodeToString(digest[:])
|
||||
}
|
||||
|
||||
func taskIdempotencyRequestHash(kind string, async bool, stream bool, body map[string]any) string {
|
||||
payload, _ := json.Marshal(map[string]any{
|
||||
"kind": kind, "async": async, "stream": stream, "request": body,
|
||||
})
|
||||
digest := sha256.Sum256(payload)
|
||||
return hex.EncodeToString(digest[:])
|
||||
}
|
||||
|
||||
func writeIdempotentTaskReplay(w http.ResponseWriter, task store.GatewayTask, compatible bool) {
|
||||
w.Header().Set("Idempotent-Replayed", "true")
|
||||
w.Header().Set("X-Gateway-Task-Id", task.ID)
|
||||
if !compatible || task.AsyncMode || (task.Status != "succeeded" && task.Status != "failed" && task.Status != "cancelled") {
|
||||
writeTaskAccepted(w, task)
|
||||
return
|
||||
}
|
||||
if task.Status == "succeeded" {
|
||||
writeJSON(w, http.StatusOK, task.Result)
|
||||
return
|
||||
}
|
||||
status := storedTaskErrorStatus(task.ErrorCode)
|
||||
message := strings.TrimSpace(task.ErrorMessage)
|
||||
if message == "" {
|
||||
message = strings.TrimSpace(task.Error)
|
||||
}
|
||||
if message == "" {
|
||||
message = "task failed"
|
||||
}
|
||||
code := strings.TrimSpace(task.ErrorCode)
|
||||
if code == "" {
|
||||
code = "task_failed"
|
||||
}
|
||||
writeError(w, status, message, code)
|
||||
}
|
||||
|
||||
func storedTaskErrorStatus(code string) int {
|
||||
switch strings.TrimSpace(code) {
|
||||
case "pricing_unavailable", "response_chain_unavailable", "billing_hold":
|
||||
return http.StatusServiceUnavailable
|
||||
case "insufficient_balance":
|
||||
return http.StatusPaymentRequired
|
||||
case "bad_request", "invalid_parameter", "invalid_previous_response_id", "unsupported_operation":
|
||||
return http.StatusBadRequest
|
||||
case "no_model_candidate", "cloned_voice_not_found":
|
||||
return http.StatusNotFound
|
||||
case "rate_limit", "platform_cooling_down", "model_cooling_down":
|
||||
return http.StatusTooManyRequests
|
||||
default:
|
||||
return http.StatusBadGateway
|
||||
}
|
||||
}
|
||||
@ -1,31 +0,0 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestOptionalTaskIdempotencyKeyRejectsMultipleValues(t *testing.T) {
|
||||
t.Parallel()
|
||||
request := httptest.NewRequest("POST", "/", nil)
|
||||
if _, present, err := optionalTaskIdempotencyKey(request); err != nil || present {
|
||||
t.Fatalf("missing key present=%v err=%v", present, err)
|
||||
}
|
||||
request.Header.Add("Idempotency-Key", "one")
|
||||
request.Header.Add("Idempotency-Key", "two")
|
||||
if _, _, err := optionalTaskIdempotencyKey(request); err == nil {
|
||||
t.Fatal("multiple keys must be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskIdempotencyRequestHashIsCanonical(t *testing.T) {
|
||||
t.Parallel()
|
||||
first := map[string]any{"model": "m", "n": float64(1), "nested": map[string]any{"b": true, "a": "x"}}
|
||||
second := map[string]any{"nested": map[string]any{"a": "x", "b": true}, "n": float64(1), "model": "m"}
|
||||
if taskIdempotencyRequestHash("images.generations", false, false, first) != taskIdempotencyRequestHash("images.generations", false, false, second) {
|
||||
t.Fatal("equivalent JSON objects must have the same request hash")
|
||||
}
|
||||
if taskIdempotencyRequestHash("images.generations", true, false, first) == taskIdempotencyRequestHash("images.generations", false, false, first) {
|
||||
t.Fatal("async response semantics must be part of the request hash")
|
||||
}
|
||||
}
|
||||
@ -1,27 +0,0 @@
|
||||
package runner
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestNormalizedBillingEngineMode(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := normalizedBillingEngineMode(""); got != "observe" {
|
||||
t.Fatalf("empty mode = %q", got)
|
||||
}
|
||||
if got := normalizedBillingEngineMode("ENFORCE"); got != "enforce" {
|
||||
t.Fatalf("enforce mode = %q", got)
|
||||
}
|
||||
if got := normalizedBillingEngineMode("hold"); got != "hold" {
|
||||
t.Fatalf("hold mode = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBillingItemsFixedTotalKeepsNineDecimalPlaces(t *testing.T) {
|
||||
t.Parallel()
|
||||
items := []any{
|
||||
map[string]any{"amount": "0.000000001"},
|
||||
map[string]any{"amount": float64(0.000000002)},
|
||||
}
|
||||
if got := billingItemsFixedTotal(items).String(); got != "0.000000003" {
|
||||
t.Fatalf("total = %s", got)
|
||||
}
|
||||
}
|
||||
@ -1,96 +0,0 @@
|
||||
package runner
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const billingSettlementPollInterval = time.Second
|
||||
|
||||
func billingSettlementRetryDelay(attempt int) time.Duration {
|
||||
if attempt < 1 {
|
||||
attempt = 1
|
||||
}
|
||||
delay := time.Second
|
||||
for current := 1; current < attempt && delay < 15*time.Minute; current++ {
|
||||
delay *= 2
|
||||
}
|
||||
if delay > 15*time.Minute {
|
||||
return 15 * time.Minute
|
||||
}
|
||||
return delay
|
||||
}
|
||||
|
||||
func billingSettlementErrorCode(err error) string {
|
||||
if errors.Is(err, store.ErrInsufficientWalletBalance) {
|
||||
return "insufficient_balance"
|
||||
}
|
||||
return "settlement_failed"
|
||||
}
|
||||
|
||||
func billingSettlementErrorMessage(code string) string {
|
||||
if code == "insufficient_balance" {
|
||||
return "wallet balance is insufficient for settlement"
|
||||
}
|
||||
return "billing settlement processing failed"
|
||||
}
|
||||
|
||||
func (s *Service) StartBillingSettlementWorker(ctx context.Context) {
|
||||
workerID := "billing-" + uuid.NewString()
|
||||
go s.runBillingSettlementWorker(ctx, workerID)
|
||||
}
|
||||
|
||||
func (s *Service) runBillingSettlementWorker(ctx context.Context, workerID string) {
|
||||
ticker := time.NewTicker(billingSettlementPollInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
s.processBillingSettlementBatch(ctx, workerID)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) processBillingSettlementBatch(ctx context.Context, workerID string) {
|
||||
items, err := s.store.ClaimBillingSettlements(ctx, workerID, store.BillingSettlementBatchSize, store.BillingSettlementLockTimeout)
|
||||
if err != nil {
|
||||
if ctx.Err() == nil {
|
||||
s.logger.Error("claim billing settlements failed", "error_category", "billing_settlement_claim_failed")
|
||||
}
|
||||
return
|
||||
}
|
||||
for _, item := range items {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
if err := s.store.ProcessBillingSettlement(ctx, item); err != nil {
|
||||
code := billingSettlementErrorCode(err)
|
||||
markErr := s.store.MarkBillingSettlementFailed(
|
||||
context.WithoutCancel(ctx),
|
||||
item,
|
||||
code,
|
||||
billingSettlementErrorMessage(code),
|
||||
billingSettlementRetryDelay(item.Attempts),
|
||||
)
|
||||
if markErr != nil {
|
||||
s.logger.Error("mark billing settlement failed", "settlementID", item.ID, "taskID", item.TaskID, "error_category", "billing_settlement_state_failed")
|
||||
continue
|
||||
}
|
||||
s.observeBillingEvent("settlement_retry")
|
||||
if item.Attempts >= store.BillingSettlementMaxAttempts {
|
||||
s.observeBillingEvent("manual_review")
|
||||
}
|
||||
s.logger.Warn("billing settlement scheduled for retry", "settlementID", item.ID, "taskID", item.TaskID, "action", item.Action, "error_category", code, "attempt", item.Attempts)
|
||||
continue
|
||||
}
|
||||
s.observeBillingEvent("settlement_completed")
|
||||
s.logger.Debug("billing settlement completed", "settlementID", item.ID, "taskID", item.TaskID, "action", item.Action)
|
||||
}
|
||||
}
|
||||
@ -1,40 +0,0 @@
|
||||
package runner
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
||||
)
|
||||
|
||||
func TestBillingSettlementRetryDelay(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
attempt int
|
||||
want time.Duration
|
||||
}{
|
||||
{attempt: 1, want: time.Second},
|
||||
{attempt: 2, want: 2 * time.Second},
|
||||
{attempt: 10, want: 512 * time.Second},
|
||||
{attempt: 11, want: 15 * time.Minute},
|
||||
{attempt: 20, want: 15 * time.Minute},
|
||||
}
|
||||
for _, test := range tests {
|
||||
if got := billingSettlementRetryDelay(test.attempt); got != test.want {
|
||||
t.Fatalf("attempt %d: got %s, want %s", test.attempt, got, test.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBillingSettlementErrorCode(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if got := billingSettlementErrorCode(store.ErrInsufficientWalletBalance); got != "insufficient_balance" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
if got := billingSettlementErrorCode(errors.New("boom")); got != "settlement_failed" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
}
|
||||
@ -1,34 +0,0 @@
|
||||
package runner
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
||||
)
|
||||
|
||||
const (
|
||||
taskExecutionLeaseTTL = 5 * time.Minute
|
||||
taskExecutionRenewInterval = 30 * time.Second
|
||||
)
|
||||
|
||||
func (s *Service) renewTaskExecutionLease(ctx context.Context, cancel context.CancelFunc, taskID string, executionToken string) {
|
||||
ticker := time.NewTicker(taskExecutionRenewInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
if err := s.store.RenewTaskExecutionLease(ctx, taskID, executionToken, taskExecutionLeaseTTL); err != nil {
|
||||
if errors.Is(err, store.ErrTaskExecutionFinished) {
|
||||
return
|
||||
}
|
||||
s.logger.Warn("task execution lease lost", "taskID", taskID, "error_category", "task_execution_lease_lost")
|
||||
cancel()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -11,14 +11,10 @@ import (
|
||||
)
|
||||
|
||||
type EstimateResult struct {
|
||||
Items []any `json:"items"`
|
||||
Resolver string `json:"resolver"`
|
||||
TotalAmount float64 `json:"totalAmount"`
|
||||
ReservationAmount float64 `json:"reservationAmount"`
|
||||
Currency string `json:"currency"`
|
||||
CandidateCount int `json:"candidateCount"`
|
||||
PricingVersion string `json:"pricingVersion"`
|
||||
RequestFingerprint string `json:"requestFingerprint"`
|
||||
Items []any `json:"items"`
|
||||
Resolver string `json:"resolver"`
|
||||
TotalAmount float64 `json:"totalAmount"`
|
||||
Currency string `json:"currency"`
|
||||
}
|
||||
|
||||
func (s *Service) Estimate(ctx context.Context, kind string, model string, body map[string]any, user *auth.User) (EstimateResult, error) {
|
||||
@ -36,19 +32,15 @@ func (s *Service) Estimate(ctx context.Context, kind string, model string, body
|
||||
if err != nil {
|
||||
return EstimateResult{}, err
|
||||
}
|
||||
estimates := make([]candidateEstimate, 0, len(candidates))
|
||||
for _, candidate := range candidates {
|
||||
preprocessing := s.preprocessRequestWithScripts(ctx, kind, body, candidate)
|
||||
if preprocessing.Err != nil {
|
||||
return EstimateResult{}, parameterPreprocessClientError(preprocessing.Err)
|
||||
}
|
||||
estimate, candidateErr := s.estimateCandidateV2(ctx, user, kind, preprocessing.Body, candidate)
|
||||
if candidateErr != nil {
|
||||
return EstimateResult{}, candidateErr
|
||||
}
|
||||
estimates = append(estimates, estimate)
|
||||
}
|
||||
return buildEstimateResult(estimates, pricingRequestFingerprint(kind, model, body))
|
||||
candidate := candidates[0]
|
||||
body = preprocessRequest(kind, body, candidate)
|
||||
items := s.estimatedBillings(ctx, user, kind, body, candidate)
|
||||
return EstimateResult{
|
||||
Items: items,
|
||||
Resolver: "effective-pricing-v1",
|
||||
TotalAmount: totalBillingAmount(items),
|
||||
Currency: billingCurrency(items),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) estimatedBillings(ctx context.Context, user *auth.User, kind string, body map[string]any, candidate store.RuntimeModelCandidate) []any {
|
||||
|
||||
@ -1,759 +0,0 @@
|
||||
package runner
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"math/big"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/clients"
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
||||
)
|
||||
|
||||
const (
|
||||
pricingVersionV2 = "effective-pricing-v2"
|
||||
fixedScale = int64(1_000_000_000)
|
||||
maxFixedAmount = fixedAmount(math.MaxInt64)
|
||||
)
|
||||
|
||||
var ErrPricingUnavailable = errors.New("pricing unavailable")
|
||||
var errFixedAmountOverflow = errors.New("fixed amount overflow")
|
||||
|
||||
type PricingUnavailableError struct {
|
||||
Reason string
|
||||
ResourceType string
|
||||
RuleSetID string
|
||||
}
|
||||
|
||||
func (e *PricingUnavailableError) Error() string {
|
||||
message := "pricing unavailable"
|
||||
if e.ResourceType != "" {
|
||||
message += " for " + e.ResourceType
|
||||
}
|
||||
if e.Reason != "" {
|
||||
message += ": " + e.Reason
|
||||
}
|
||||
return message
|
||||
}
|
||||
|
||||
func (e *PricingUnavailableError) Unwrap() error { return ErrPricingUnavailable }
|
||||
|
||||
func isPricingUnavailable(err error) bool { return errors.Is(err, ErrPricingUnavailable) }
|
||||
|
||||
func IsPricingUnavailable(err error) bool { return errors.Is(err, ErrPricingUnavailable) }
|
||||
|
||||
func PricingUnavailableDetails(err error) map[string]any {
|
||||
var pricingErr *PricingUnavailableError
|
||||
if !errors.As(err, &pricingErr) {
|
||||
return nil
|
||||
}
|
||||
details := map[string]any{"reason": pricingErr.Reason}
|
||||
if pricingErr.ResourceType != "" {
|
||||
details["resourceType"] = pricingErr.ResourceType
|
||||
}
|
||||
if pricingErr.RuleSetID != "" {
|
||||
details["ruleSetId"] = pricingErr.RuleSetID
|
||||
}
|
||||
return details
|
||||
}
|
||||
|
||||
// fixedAmount is a signed decimal with exactly nine fractional digits.
|
||||
// Monetary decisions use this type; float64 is only produced at JSON-compatible boundaries.
|
||||
type fixedAmount int64
|
||||
|
||||
func parseFixedAmount(value string) (fixedAmount, error) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return 0, fmt.Errorf("empty decimal")
|
||||
}
|
||||
sign := int64(1)
|
||||
if value[0] == '-' || value[0] == '+' {
|
||||
if value[0] == '-' {
|
||||
sign = -1
|
||||
}
|
||||
value = value[1:]
|
||||
}
|
||||
parts := strings.Split(value, ".")
|
||||
if len(parts) > 2 || value == "" {
|
||||
return 0, fmt.Errorf("invalid decimal %q", value)
|
||||
}
|
||||
whole := parts[0]
|
||||
if whole == "" {
|
||||
whole = "0"
|
||||
}
|
||||
fraction := ""
|
||||
if len(parts) == 2 {
|
||||
fraction = parts[1]
|
||||
}
|
||||
for _, digits := range []string{whole, fraction} {
|
||||
for _, char := range digits {
|
||||
if char < '0' || char > '9' {
|
||||
return 0, fmt.Errorf("invalid decimal %q", value)
|
||||
}
|
||||
}
|
||||
}
|
||||
roundUp := false
|
||||
if len(fraction) > 9 {
|
||||
roundUp = fraction[9] >= '5'
|
||||
fraction = fraction[:9]
|
||||
}
|
||||
fraction += strings.Repeat("0", 9-len(fraction))
|
||||
combined := strings.TrimLeft(whole+fraction, "0")
|
||||
if combined == "" {
|
||||
combined = "0"
|
||||
}
|
||||
number := new(big.Int)
|
||||
if _, ok := number.SetString(combined, 10); !ok {
|
||||
return 0, fmt.Errorf("invalid decimal %q", value)
|
||||
}
|
||||
if roundUp {
|
||||
number.Add(number, big.NewInt(1))
|
||||
}
|
||||
if !number.IsInt64() {
|
||||
return 0, fmt.Errorf("decimal %q exceeds fixed amount range", value)
|
||||
}
|
||||
return fixedAmount(sign * number.Int64()), nil
|
||||
}
|
||||
|
||||
func fixedAmountFromAny(value any) (fixedAmount, error) {
|
||||
switch typed := value.(type) {
|
||||
case fixedAmount:
|
||||
return typed, nil
|
||||
case string:
|
||||
return parseFixedAmount(typed)
|
||||
case json.Number:
|
||||
return parseFixedAmount(typed.String())
|
||||
case int:
|
||||
return parseFixedAmount(strconv.FormatInt(int64(typed), 10))
|
||||
case int64:
|
||||
return parseFixedAmount(strconv.FormatInt(typed, 10))
|
||||
case int32:
|
||||
return parseFixedAmount(strconv.FormatInt(int64(typed), 10))
|
||||
case float64:
|
||||
if math.IsNaN(typed) || math.IsInf(typed, 0) {
|
||||
return 0, fmt.Errorf("non-finite decimal")
|
||||
}
|
||||
return parseFixedAmount(strconv.FormatFloat(typed, 'f', 9, 64))
|
||||
case float32:
|
||||
return fixedAmountFromAny(float64(typed))
|
||||
default:
|
||||
return 0, fmt.Errorf("unsupported decimal type %T", value)
|
||||
}
|
||||
}
|
||||
|
||||
func (a fixedAmount) String() string {
|
||||
value := int64(a)
|
||||
sign := ""
|
||||
if value < 0 {
|
||||
sign = "-"
|
||||
value = -value
|
||||
}
|
||||
return fmt.Sprintf("%s%d.%09d", sign, value/fixedScale, value%fixedScale)
|
||||
}
|
||||
|
||||
func (a fixedAmount) Float64() float64 { return float64(a) / float64(fixedScale) }
|
||||
func (a fixedAmount) IsZero() bool { return a == 0 }
|
||||
|
||||
func addFixedAmounts(left fixedAmount, right fixedAmount) (fixedAmount, error) {
|
||||
value := new(big.Int).Add(big.NewInt(int64(left)), big.NewInt(int64(right)))
|
||||
return fixedAmountFromBigInt(value)
|
||||
}
|
||||
|
||||
func multiplyFixedAmountByInt(amount fixedAmount, multiplier int) (fixedAmount, error) {
|
||||
value := new(big.Int).Mul(big.NewInt(int64(amount)), big.NewInt(int64(multiplier)))
|
||||
return fixedAmountFromBigInt(value)
|
||||
}
|
||||
|
||||
func multiplyFixedAmounts(left fixedAmount, right fixedAmount) (fixedAmount, error) {
|
||||
product := new(big.Int).Mul(big.NewInt(int64(left)), big.NewInt(int64(right)))
|
||||
return fixedAmountFromBigInt(roundBigIntRatio(product, big.NewInt(fixedScale)))
|
||||
}
|
||||
|
||||
func multiplyFixedAmountRatio(amount fixedAmount, numerator int, denominator int) (fixedAmount, error) {
|
||||
if denominator == 0 {
|
||||
return 0, fmt.Errorf("division by zero")
|
||||
}
|
||||
product := new(big.Int).Mul(big.NewInt(int64(amount)), big.NewInt(int64(numerator)))
|
||||
return fixedAmountFromBigInt(roundBigIntRatio(product, big.NewInt(int64(denominator))))
|
||||
}
|
||||
|
||||
func fixedAmountFromBigInt(value *big.Int) (fixedAmount, error) {
|
||||
if value == nil || !value.IsInt64() {
|
||||
return 0, errFixedAmountOverflow
|
||||
}
|
||||
return fixedAmount(value.Int64()), nil
|
||||
}
|
||||
|
||||
func roundBigIntRatio(numerator *big.Int, denominator *big.Int) *big.Int {
|
||||
negative := numerator.Sign() < 0
|
||||
absolute := new(big.Int).Abs(new(big.Int).Set(numerator))
|
||||
quotient, remainder := new(big.Int), new(big.Int)
|
||||
quotient.QuoRem(absolute, denominator, remainder)
|
||||
if new(big.Int).Mul(remainder, big.NewInt(2)).Cmp(denominator) >= 0 {
|
||||
quotient.Add(quotient, big.NewInt(1))
|
||||
}
|
||||
if negative {
|
||||
quotient.Neg(quotient)
|
||||
}
|
||||
return quotient
|
||||
}
|
||||
|
||||
type resolvedPricing struct {
|
||||
Config map[string]any
|
||||
Currency string
|
||||
RuleSetID string
|
||||
RuleSetKey string
|
||||
Source string
|
||||
FreeResource map[string]bool
|
||||
Snapshot map[string]any
|
||||
}
|
||||
|
||||
func (pricing resolvedPricing) requiredPrice(resource string, keys ...string) (fixedAmount, error) {
|
||||
if price, found, err := pricing.price(resource, keys...); err != nil {
|
||||
return 0, &PricingUnavailableError{Reason: err.Error(), ResourceType: resource, RuleSetID: pricing.RuleSetID}
|
||||
} else if found && price > 0 {
|
||||
return price, nil
|
||||
} else if found && price == 0 && pricing.FreeResource[resource] {
|
||||
return 0, nil
|
||||
}
|
||||
return 0, &PricingUnavailableError{Reason: "missing, invalid, or not explicitly free", ResourceType: resource, RuleSetID: pricing.RuleSetID}
|
||||
}
|
||||
|
||||
func (pricing resolvedPricing) price(resource string, keys ...string) (fixedAmount, bool, error) {
|
||||
for _, key := range keys {
|
||||
if value, ok := pricing.Config[key]; ok {
|
||||
amount, err := fixedAmountFromAny(value)
|
||||
return amount, true, err
|
||||
}
|
||||
}
|
||||
resourceConfig, _ := pricing.Config[resource].(map[string]any)
|
||||
if len(resourceConfig) == 0 && resource == "image_edit" {
|
||||
resourceConfig, _ = pricing.Config["image"].(map[string]any)
|
||||
}
|
||||
for _, key := range keys {
|
||||
if value, ok := resourceConfig[key]; ok {
|
||||
amount, err := fixedAmountFromAny(value)
|
||||
return amount, true, err
|
||||
}
|
||||
}
|
||||
if formula, ok := resourceConfig["formulaConfig"].(map[string]any); ok {
|
||||
for _, key := range keys {
|
||||
if value, exists := formula[key]; exists {
|
||||
amount, err := fixedAmountFromAny(value)
|
||||
return amount, true, err
|
||||
}
|
||||
}
|
||||
}
|
||||
if value, ok := resourceConfig["basePrice"]; ok {
|
||||
amount, err := fixedAmountFromAny(value)
|
||||
return amount, true, err
|
||||
}
|
||||
if resource == "text" {
|
||||
return resolvedPricing{Config: pricing.Config}.price("text_total", keys...)
|
||||
}
|
||||
return 0, false, nil
|
||||
}
|
||||
|
||||
func (pricing resolvedPricing) weight(resource string, key string, name string) (fixedAmount, error) {
|
||||
if strings.TrimSpace(name) == "" {
|
||||
return fixedAmount(fixedScale), nil
|
||||
}
|
||||
keys := weightKeyAliases(key)
|
||||
names := weightValueAliases(key, name)
|
||||
value, found := pricingWeightValue(pricing.Config, resource, keys, names)
|
||||
if !found {
|
||||
return fixedAmount(fixedScale), nil
|
||||
}
|
||||
weight, err := fixedAmountFromAny(value)
|
||||
if err != nil || weight <= 0 {
|
||||
reason := fmt.Sprintf("invalid %s weight for %s", key, name)
|
||||
if err != nil {
|
||||
reason += ": " + err.Error()
|
||||
}
|
||||
return 0, &PricingUnavailableError{Reason: reason, ResourceType: resource, RuleSetID: pricing.RuleSetID}
|
||||
}
|
||||
return weight, nil
|
||||
}
|
||||
|
||||
func (pricing resolvedPricing) calculate(resource string, base fixedAmount, integerFactors []int, fixedFactors ...fixedAmount) (fixedAmount, error) {
|
||||
amount := base
|
||||
var err error
|
||||
for _, factor := range integerFactors {
|
||||
amount, err = multiplyFixedAmountByInt(amount, factor)
|
||||
if err != nil {
|
||||
return 0, pricing.calculationError(resource, err)
|
||||
}
|
||||
}
|
||||
for _, factor := range fixedFactors {
|
||||
amount, err = multiplyFixedAmounts(amount, factor)
|
||||
if err != nil {
|
||||
return 0, pricing.calculationError(resource, err)
|
||||
}
|
||||
}
|
||||
return amount, nil
|
||||
}
|
||||
|
||||
func (pricing resolvedPricing) calculateRatio(resource string, base fixedAmount, numerator int, denominator int, fixedFactors ...fixedAmount) (fixedAmount, error) {
|
||||
amount, err := multiplyFixedAmountRatio(base, numerator, denominator)
|
||||
if err != nil {
|
||||
return 0, pricing.calculationError(resource, err)
|
||||
}
|
||||
return pricing.calculate(resource, amount, nil, fixedFactors...)
|
||||
}
|
||||
|
||||
func (pricing resolvedPricing) add(resource string, left fixedAmount, right fixedAmount) (fixedAmount, error) {
|
||||
amount, err := addFixedAmounts(left, right)
|
||||
if err != nil {
|
||||
return 0, pricing.calculationError(resource, err)
|
||||
}
|
||||
return amount, nil
|
||||
}
|
||||
|
||||
func (pricing resolvedPricing) calculationError(resource string, err error) error {
|
||||
return &PricingUnavailableError{Reason: "pricing calculation failed: " + err.Error(), ResourceType: resource, RuleSetID: pricing.RuleSetID}
|
||||
}
|
||||
|
||||
func pricingWeightValue(config map[string]any, resource string, keys []string, names []string) (any, bool) {
|
||||
if value, ok := pricingWeightValueFromConfig(config, keys, names); ok {
|
||||
return value, true
|
||||
}
|
||||
resourceConfig, _ := config[resource].(map[string]any)
|
||||
if len(resourceConfig) == 0 && resource == "image_edit" {
|
||||
resourceConfig, _ = config["image"].(map[string]any)
|
||||
}
|
||||
return pricingWeightValueFromConfig(resourceConfig, keys, names)
|
||||
}
|
||||
|
||||
func pricingWeightValueFromConfig(config map[string]any, keys []string, names []string) (any, bool) {
|
||||
if len(config) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
for _, key := range keys {
|
||||
weights, _ := config[key].(map[string]any)
|
||||
for _, name := range names {
|
||||
if value, ok := weights[name]; ok {
|
||||
return value, true
|
||||
}
|
||||
}
|
||||
}
|
||||
dynamic, _ := config["dynamicWeight"].(map[string]any)
|
||||
for _, name := range names {
|
||||
if value, ok := dynamic[name]; ok {
|
||||
return value, true
|
||||
}
|
||||
}
|
||||
for _, key := range keys {
|
||||
weights, _ := dynamic[key].(map[string]any)
|
||||
for _, name := range names {
|
||||
if value, ok := weights[name]; ok {
|
||||
return value, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
type candidateEstimate struct {
|
||||
Items []any
|
||||
Amount fixedAmount
|
||||
Currency string
|
||||
Snapshot map[string]any
|
||||
Pricing resolvedPricing
|
||||
}
|
||||
|
||||
func buildEstimateResult(estimates []candidateEstimate, fingerprint string) (EstimateResult, error) {
|
||||
if len(estimates) == 0 {
|
||||
return EstimateResult{}, &PricingUnavailableError{Reason: "no candidate has effective pricing"}
|
||||
}
|
||||
preferred := estimates[0]
|
||||
reservation := preferred.Amount
|
||||
currency := preferred.Currency
|
||||
if currency == "" {
|
||||
currency = "resource"
|
||||
}
|
||||
for _, estimate := range estimates[1:] {
|
||||
candidateCurrency := estimate.Currency
|
||||
if candidateCurrency == "" {
|
||||
candidateCurrency = "resource"
|
||||
}
|
||||
if candidateCurrency != currency {
|
||||
return EstimateResult{}, &PricingUnavailableError{Reason: "candidate currencies do not match"}
|
||||
}
|
||||
if estimate.Amount > reservation {
|
||||
reservation = estimate.Amount
|
||||
}
|
||||
}
|
||||
return EstimateResult{
|
||||
Items: preferred.Items,
|
||||
Resolver: pricingVersionV2,
|
||||
TotalAmount: preferred.Amount.Float64(),
|
||||
ReservationAmount: reservation.Float64(),
|
||||
Currency: currency,
|
||||
CandidateCount: len(estimates),
|
||||
PricingVersion: pricingVersionV2,
|
||||
RequestFingerprint: fingerprint,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func estimatedOutputTokens(body map[string]any, candidate store.RuntimeModelCandidate) int {
|
||||
for _, key := range []string{"max_completion_tokens", "max_output_tokens", "max_tokens"} {
|
||||
if value, ok := positiveInteger(body[key]); ok {
|
||||
return value
|
||||
}
|
||||
}
|
||||
if limit, _, _, ok := candidateMaxOutputTokens(candidate, candidate.ModelType); ok {
|
||||
return limit
|
||||
}
|
||||
return 4096
|
||||
}
|
||||
|
||||
func pricingRequestFingerprint(kind string, model string, body map[string]any) string {
|
||||
payload, _ := json.Marshal(map[string]any{"kind": kind, "model": model, "request": body})
|
||||
digest := sha256.Sum256(payload)
|
||||
return hex.EncodeToString(digest[:])
|
||||
}
|
||||
|
||||
func (s *Service) resolveEffectivePricing(ctx context.Context, candidate store.RuntimeModelCandidate) (resolvedPricing, error) {
|
||||
ruleSetID := firstNonEmptyString(
|
||||
candidate.ModelPricingRuleSetID,
|
||||
candidate.PlatformPricingRuleSetID,
|
||||
candidate.BasePricingRuleSetID,
|
||||
)
|
||||
if ruleSetID != "" {
|
||||
if s.store == nil {
|
||||
return resolvedPricing{}, &PricingUnavailableError{Reason: "pricing store is unavailable", RuleSetID: ruleSetID}
|
||||
}
|
||||
config, err := s.store.PricingRuleSetBillingConfigV2(ctx, ruleSetID)
|
||||
if err != nil {
|
||||
return resolvedPricing{}, &PricingUnavailableError{Reason: err.Error(), RuleSetID: ruleSetID}
|
||||
}
|
||||
if len(candidate.BillingConfigOverride) > 0 {
|
||||
config.Config = mergeMap(config.Config, candidate.BillingConfigOverride)
|
||||
}
|
||||
return resolvedPricing{
|
||||
Config: config.Config, Currency: config.Currency, RuleSetID: config.RuleSetID,
|
||||
RuleSetKey: config.RuleSetKey, Source: pricingRuleSource(candidate, ruleSetID),
|
||||
FreeResource: config.FreeResource, Snapshot: config.Snapshot,
|
||||
}, nil
|
||||
}
|
||||
|
||||
config := candidate.BaseBillingConfig
|
||||
source := "base_model_config"
|
||||
if len(candidate.BillingConfig) > 0 {
|
||||
config = candidate.BillingConfig
|
||||
source = "platform_model_config"
|
||||
}
|
||||
if len(candidate.BillingConfigOverride) > 0 {
|
||||
config = mergeMap(config, candidate.BillingConfigOverride)
|
||||
source += "_override"
|
||||
}
|
||||
if len(config) == 0 {
|
||||
return resolvedPricing{}, &PricingUnavailableError{Reason: "candidate has no pricing configuration"}
|
||||
}
|
||||
freeResource := explicitFreeResources(config)
|
||||
snapshot := map[string]any{
|
||||
"pricingVersion": pricingVersionV2,
|
||||
"source": source,
|
||||
"currency": "resource",
|
||||
"config": config,
|
||||
}
|
||||
return resolvedPricing{
|
||||
Config: config, Currency: "resource", Source: source,
|
||||
FreeResource: freeResource, Snapshot: snapshot,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func pricingRuleSource(candidate store.RuntimeModelCandidate, ruleSetID string) string {
|
||||
switch ruleSetID {
|
||||
case candidate.ModelPricingRuleSetID:
|
||||
return "platform_model_rule_set"
|
||||
case candidate.PlatformPricingRuleSetID:
|
||||
return "platform_rule_set"
|
||||
default:
|
||||
return "base_model_rule_set"
|
||||
}
|
||||
}
|
||||
|
||||
func explicitFreeResources(config map[string]any) map[string]bool {
|
||||
resources := map[string]bool{}
|
||||
if isFree, _ := config["isFree"].(bool); isFree {
|
||||
for _, resource := range []string{"text_input", "text_cached_input", "text_output", "text_total", "image", "image_edit", "video", "music", "audio"} {
|
||||
resources[resource] = true
|
||||
}
|
||||
}
|
||||
for _, resource := range []string{"text", "text_total", "image", "image_edit", "video", "music", "audio"} {
|
||||
resourceConfig, _ := config[resource].(map[string]any)
|
||||
if isFree, _ := resourceConfig["isFree"].(bool); isFree {
|
||||
resources[resource] = true
|
||||
}
|
||||
}
|
||||
return resources
|
||||
}
|
||||
|
||||
func (s *Service) estimateCandidateV2(ctx context.Context, user *auth.User, kind string, body map[string]any, candidate store.RuntimeModelCandidate) (candidateEstimate, error) {
|
||||
usage := clients.Usage{InputTokens: estimateRequestTokens(body)}
|
||||
if isTextGenerationKind(kind) {
|
||||
usage.OutputTokens = estimatedOutputTokens(body, candidate)
|
||||
}
|
||||
usage.TotalTokens = usage.InputTokens + usage.OutputTokens
|
||||
response := clients.Response{Usage: usage}
|
||||
items, total, pricing, err := s.billingsV2(ctx, user, kind, body, candidate, response, true)
|
||||
if err != nil {
|
||||
return candidateEstimate{}, err
|
||||
}
|
||||
if isTextBillingKind(kind) {
|
||||
if _, err := pricing.requiredTextPrice("text_cached_input", "textCachedInputPer1k", "cachedInputTokenPrice", "cacheInputTokenPrice", "textInputCacheHitPer1k", "inputCacheHitTokenPrice"); err != nil {
|
||||
return candidateEstimate{}, err
|
||||
}
|
||||
}
|
||||
return candidateEstimate{
|
||||
Items: items, Amount: total, Currency: pricing.Currency, Snapshot: pricing.Snapshot, Pricing: pricing,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) billingsV2(
|
||||
ctx context.Context,
|
||||
user *auth.User,
|
||||
kind string,
|
||||
body map[string]any,
|
||||
candidate store.RuntimeModelCandidate,
|
||||
response clients.Response,
|
||||
simulated bool,
|
||||
) ([]any, fixedAmount, resolvedPricing, error) {
|
||||
pricing, err := s.resolveEffectivePricing(ctx, candidate)
|
||||
if err != nil {
|
||||
return nil, 0, resolvedPricing{}, err
|
||||
}
|
||||
return s.billingsWithResolvedPricingV2(ctx, user, kind, body, candidate, response, simulated, pricing)
|
||||
}
|
||||
|
||||
func (s *Service) billingsWithResolvedPricingV2(
|
||||
ctx context.Context,
|
||||
user *auth.User,
|
||||
kind string,
|
||||
body map[string]any,
|
||||
candidate store.RuntimeModelCandidate,
|
||||
response clients.Response,
|
||||
simulated bool,
|
||||
pricing resolvedPricing,
|
||||
) ([]any, fixedAmount, resolvedPricing, error) {
|
||||
discount, err := fixedAmountFromAny(effectiveDiscount(ctx, s.store, user, candidate))
|
||||
if err != nil || discount <= 0 {
|
||||
return nil, 0, resolvedPricing{}, &PricingUnavailableError{Reason: "invalid discount factor", RuleSetID: pricing.RuleSetID}
|
||||
}
|
||||
buildLine := func(resourceType string, unit string, quantity any, amount fixedAmount, details map[string]any) map[string]any {
|
||||
line := billingLineWithDetails(candidate, resourceType, unit, quantity, amount.Float64(), discount.Float64(), simulated, details)
|
||||
line["pricingVersion"] = pricingVersionV2
|
||||
line["pricingSource"] = pricing.Source
|
||||
if pricing.RuleSetID != "" {
|
||||
line["pricingRuleSetId"] = pricing.RuleSetID
|
||||
}
|
||||
line["isFree"] = amount.IsZero()
|
||||
return line
|
||||
}
|
||||
|
||||
if isTextBillingKind(kind) {
|
||||
inputTokens := response.Usage.InputTokens
|
||||
outputTokens := response.Usage.OutputTokens
|
||||
cachedInputTokens := response.Usage.CachedInputTokens
|
||||
if isTextInputOnlyKind(kind) && inputTokens == 0 && response.Usage.TotalTokens > 0 {
|
||||
inputTokens = response.Usage.TotalTokens
|
||||
}
|
||||
if inputTokens == 0 && outputTokens == 0 {
|
||||
inputTokens = estimateRequestTokens(body)
|
||||
if isTextGenerationKind(kind) {
|
||||
outputTokens = 1
|
||||
}
|
||||
}
|
||||
if cachedInputTokens > inputTokens && inputTokens > 0 {
|
||||
cachedInputTokens = inputTokens
|
||||
}
|
||||
uncachedInputTokens := inputTokens - cachedInputTokens
|
||||
if uncachedInputTokens < 0 {
|
||||
uncachedInputTokens = 0
|
||||
}
|
||||
inputPrice, err := pricing.requiredTextPrice("text_input", "textInputPer1k", "inputTokenPrice", "basePrice")
|
||||
if err != nil {
|
||||
return nil, 0, resolvedPricing{}, err
|
||||
}
|
||||
items := make([]any, 0, 3)
|
||||
total := fixedAmount(0)
|
||||
if uncachedInputTokens > 0 || cachedInputTokens == 0 {
|
||||
amount, calculationErr := pricing.calculateRatio("text_input", inputPrice, uncachedInputTokens, 1000, discount)
|
||||
if calculationErr != nil {
|
||||
return nil, 0, resolvedPricing{}, calculationErr
|
||||
}
|
||||
total, calculationErr = pricing.add("text", total, amount)
|
||||
if calculationErr != nil {
|
||||
return nil, 0, resolvedPricing{}, calculationErr
|
||||
}
|
||||
items = append(items, buildLine("text_input", "1k_tokens", uncachedInputTokens, amount, map[string]any{
|
||||
"inputTokens": inputTokens, "uncachedInputTokens": uncachedInputTokens,
|
||||
"cachedInputTokens": cachedInputTokens, "pricePer1k": inputPrice.Float64(),
|
||||
}))
|
||||
}
|
||||
if cachedInputTokens > 0 {
|
||||
cachedPrice, priceErr := pricing.requiredTextPrice("text_cached_input", "textCachedInputPer1k", "cachedInputTokenPrice", "cacheInputTokenPrice", "textInputCacheHitPer1k", "inputCacheHitTokenPrice")
|
||||
if priceErr != nil {
|
||||
return nil, 0, resolvedPricing{}, priceErr
|
||||
}
|
||||
amount, calculationErr := pricing.calculateRatio("text_cached_input", cachedPrice, cachedInputTokens, 1000, discount)
|
||||
if calculationErr != nil {
|
||||
return nil, 0, resolvedPricing{}, calculationErr
|
||||
}
|
||||
total, calculationErr = pricing.add("text", total, amount)
|
||||
if calculationErr != nil {
|
||||
return nil, 0, resolvedPricing{}, calculationErr
|
||||
}
|
||||
items = append(items, buildLine("text_cached_input", "1k_tokens", cachedInputTokens, amount, map[string]any{
|
||||
"inputTokens": inputTokens, "uncachedInputTokens": uncachedInputTokens,
|
||||
"cachedInputTokens": cachedInputTokens, "pricePer1k": cachedPrice.Float64(),
|
||||
}))
|
||||
}
|
||||
if isTextGenerationKind(kind) {
|
||||
outputPrice, priceErr := pricing.requiredTextPrice("text_output", "textOutputPer1k", "outputTokenPrice", "basePrice")
|
||||
if priceErr != nil {
|
||||
return nil, 0, resolvedPricing{}, priceErr
|
||||
}
|
||||
amount, calculationErr := pricing.calculateRatio("text_output", outputPrice, outputTokens, 1000, discount)
|
||||
if calculationErr != nil {
|
||||
return nil, 0, resolvedPricing{}, calculationErr
|
||||
}
|
||||
total, calculationErr = pricing.add("text", total, amount)
|
||||
if calculationErr != nil {
|
||||
return nil, 0, resolvedPricing{}, calculationErr
|
||||
}
|
||||
items = append(items, buildLine("text_output", "1k_tokens", outputTokens, amount, map[string]any{"pricePer1k": outputPrice.Float64()}))
|
||||
}
|
||||
return items, total, pricing, nil
|
||||
}
|
||||
|
||||
count := requestOutputCount(body)
|
||||
resource := "image"
|
||||
unit := "image"
|
||||
baseKey := "imageBase"
|
||||
if kind == "images.edits" {
|
||||
resource = "image_edit"
|
||||
baseKey = "editBase"
|
||||
}
|
||||
if kind == "videos.generations" {
|
||||
resource = "video"
|
||||
unit = "5s_video"
|
||||
baseKey = "videoBase"
|
||||
duration, durationSource := billingDurationSeconds(body, response)
|
||||
audioEnabled, audioSource := billingAudioEnabled(body, response)
|
||||
durationUnits := int(math.Max(1, math.Ceil(duration/5)))
|
||||
price, priceErr := pricing.requiredPrice(resource, baseKey, "basePrice")
|
||||
if priceErr != nil {
|
||||
return nil, 0, resolvedPricing{}, priceErr
|
||||
}
|
||||
resolutionWeight, weightErr := pricing.weight(resource, "resolutionWeights", firstNonEmptyString(stringFromMap(body, "resolution"), stringFromMap(body, "size")))
|
||||
if weightErr != nil {
|
||||
return nil, 0, resolvedPricing{}, weightErr
|
||||
}
|
||||
audioWeight, weightErr := pricing.weight(resource, "audioWeights", boolWeightKey(audioEnabled))
|
||||
if weightErr != nil {
|
||||
return nil, 0, resolvedPricing{}, weightErr
|
||||
}
|
||||
referenceVideoWeight, weightErr := pricing.weight(resource, "referenceVideoWeights", boolWeightKey(requestHasReferenceVideo(body)))
|
||||
if weightErr != nil {
|
||||
return nil, 0, resolvedPricing{}, weightErr
|
||||
}
|
||||
voiceWeight, weightErr := pricing.weight(resource, "voiceSpecifiedWeights", boolWeightKey(requestHasVoiceID(body, audioEnabled)))
|
||||
if weightErr != nil {
|
||||
return nil, 0, resolvedPricing{}, weightErr
|
||||
}
|
||||
amount, calculationErr := pricing.calculate(resource, price, []int{count, durationUnits}, resolutionWeight, audioWeight, referenceVideoWeight, voiceWeight, discount)
|
||||
if calculationErr != nil {
|
||||
return nil, 0, resolvedPricing{}, calculationErr
|
||||
}
|
||||
item := buildLine(resource, unit, count*durationUnits, amount, map[string]any{
|
||||
"count": count, "audio": audioEnabled, "audioSource": audioSource,
|
||||
"durationSeconds": duration, "durationSource": durationSource,
|
||||
"durationUnit": "5s", "durationUnitCount": durationUnits,
|
||||
})
|
||||
return []any{item}, amount, pricing, nil
|
||||
}
|
||||
if kind == "song.generations" || kind == "music.generations" {
|
||||
resource = "music"
|
||||
unit = "song"
|
||||
baseKey = "musicBase"
|
||||
}
|
||||
if kind == "speech.generations" || kind == "voice.clone" {
|
||||
resource = "audio"
|
||||
unit = "character"
|
||||
baseKey = "audioBase"
|
||||
count = len([]rune(stringFromMap(body, "text")))
|
||||
if count <= 0 {
|
||||
count = 1
|
||||
}
|
||||
}
|
||||
price, err := pricing.requiredPrice(resource, baseKey, "basePrice")
|
||||
if err != nil {
|
||||
return nil, 0, resolvedPricing{}, err
|
||||
}
|
||||
amount, calculationErr := pricing.calculate(resource, price, []int{count})
|
||||
if calculationErr != nil {
|
||||
return nil, 0, resolvedPricing{}, calculationErr
|
||||
}
|
||||
if resource == "image" || resource == "image_edit" {
|
||||
qualityWeight, weightErr := pricing.weight(resource, "qualityWeights", stringFromMap(body, "quality"))
|
||||
if weightErr != nil {
|
||||
return nil, 0, resolvedPricing{}, weightErr
|
||||
}
|
||||
sizeWeight, weightErr := pricing.weight(resource, "sizeWeights", stringFromMap(body, "size"))
|
||||
if weightErr != nil {
|
||||
return nil, 0, resolvedPricing{}, weightErr
|
||||
}
|
||||
resolutionWeight, weightErr := pricing.weight(resource, "resolutionWeights", firstNonEmptyString(stringFromMap(body, "resolution"), stringFromMap(body, "size")))
|
||||
if weightErr != nil {
|
||||
return nil, 0, resolvedPricing{}, weightErr
|
||||
}
|
||||
amount, calculationErr = pricing.calculate(resource, amount, nil, qualityWeight, sizeWeight, resolutionWeight)
|
||||
if calculationErr != nil {
|
||||
return nil, 0, resolvedPricing{}, calculationErr
|
||||
}
|
||||
}
|
||||
amount, calculationErr = pricing.calculate(resource, amount, nil, discount)
|
||||
if calculationErr != nil {
|
||||
return nil, 0, resolvedPricing{}, calculationErr
|
||||
}
|
||||
return []any{buildLine(resource, unit, count, amount, nil)}, amount, pricing, nil
|
||||
}
|
||||
|
||||
func (pricing resolvedPricing) requiredTextPrice(resource string, keys ...string) (fixedAmount, error) {
|
||||
price, found, err := pricing.price("text", keys...)
|
||||
if err != nil {
|
||||
return 0, &PricingUnavailableError{Reason: err.Error(), ResourceType: resource, RuleSetID: pricing.RuleSetID}
|
||||
}
|
||||
if found && price > 0 {
|
||||
return price, nil
|
||||
}
|
||||
if found && price == 0 && (pricing.FreeResource[resource] || pricing.FreeResource["text_total"] || pricing.FreeResource["text"]) {
|
||||
return 0, nil
|
||||
}
|
||||
return 0, &PricingUnavailableError{Reason: "missing, invalid, or not explicitly free", ResourceType: resource, RuleSetID: pricing.RuleSetID}
|
||||
}
|
||||
|
||||
func maximumCandidateEstimate(estimates []candidateEstimate) (candidateEstimate, error) {
|
||||
if len(estimates) == 0 {
|
||||
return candidateEstimate{}, &PricingUnavailableError{Reason: "no candidate has effective pricing"}
|
||||
}
|
||||
maximum := estimates[0]
|
||||
for _, estimate := range estimates[1:] {
|
||||
if estimate.Currency != maximum.Currency {
|
||||
return candidateEstimate{}, &PricingUnavailableError{Reason: "candidate currencies do not match"}
|
||||
}
|
||||
if estimate.Amount > maximum.Amount {
|
||||
maximum = estimate
|
||||
}
|
||||
}
|
||||
return maximum, nil
|
||||
}
|
||||
@ -1,148 +0,0 @@
|
||||
package runner
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"math"
|
||||
"testing"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
||||
)
|
||||
|
||||
func TestFixedAmountPreservesNineDecimalPlaces(t *testing.T) {
|
||||
amount, err := parseFixedAmount("0.123456789")
|
||||
if err != nil {
|
||||
t.Fatalf("parse fixed amount: %v", err)
|
||||
}
|
||||
if got, want := amount.String(), "0.123456789"; got != want {
|
||||
t.Fatalf("amount.String()=%q, want %q", got, want)
|
||||
}
|
||||
|
||||
price := mustFixedAmount(t, "0.000000001")
|
||||
product, err := multiplyFixedAmountByInt(price, 3)
|
||||
if err != nil {
|
||||
t.Fatalf("multiply fixed amount: %v", err)
|
||||
}
|
||||
if got, want := product.String(), "0.000000003"; got != want {
|
||||
t.Fatalf("nano amount multiplication=%q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFixedAmountOperationsRejectOverflow(t *testing.T) {
|
||||
maximum := fixedAmount(math.MaxInt64)
|
||||
if _, err := multiplyFixedAmountByInt(maximum, 2); !errors.Is(err, errFixedAmountOverflow) {
|
||||
t.Fatalf("multiply overflow error=%v", err)
|
||||
}
|
||||
if _, err := addFixedAmounts(maximum, 1); !errors.Is(err, errFixedAmountOverflow) {
|
||||
t.Fatalf("add overflow error=%v", err)
|
||||
}
|
||||
pricing := resolvedPricing{RuleSetID: "overflow-rule"}
|
||||
if _, err := pricing.calculate("image", maximum, []int{2}); !isPricingUnavailable(err) {
|
||||
t.Fatalf("pricing overflow should be unavailable: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEstimatedOutputTokensUsesAliasesAndCapabilityFallback(t *testing.T) {
|
||||
candidate := store.RuntimeModelCandidate{
|
||||
ModelType: "text_generate",
|
||||
Capabilities: map[string]any{
|
||||
"text_generate": map[string]any{"max_output_tokens": 8192},
|
||||
},
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
body map[string]any
|
||||
want int
|
||||
}{
|
||||
{name: "max completion has highest priority", body: map[string]any{"max_completion_tokens": 700, "max_output_tokens": 600, "max_tokens": 500}, want: 700},
|
||||
{name: "max output precedes legacy max tokens", body: map[string]any{"max_output_tokens": 600, "max_tokens": 500}, want: 600},
|
||||
{name: "legacy max tokens", body: map[string]any{"max_tokens": 500}, want: 500},
|
||||
{name: "model capability fallback", body: map[string]any{}, want: 8192},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if got := estimatedOutputTokens(test.body, candidate); got != test.want {
|
||||
t.Fatalf("estimatedOutputTokens()=%d, want %d", got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if got := estimatedOutputTokens(nil, store.RuntimeModelCandidate{}); got != 4096 {
|
||||
t.Fatalf("missing capability fallback=%d, want 4096", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildEstimateResultUsesPreferredTotalAndMaximumReservation(t *testing.T) {
|
||||
result, err := buildEstimateResult([]candidateEstimate{
|
||||
{Items: []any{map[string]any{"amount": 1.25, "currency": "resource"}}, Amount: mustFixedAmount(t, "1.25")},
|
||||
{Items: []any{map[string]any{"amount": 2.75, "currency": "resource"}}, Amount: mustFixedAmount(t, "2.75")},
|
||||
}, "fingerprint")
|
||||
if err != nil {
|
||||
t.Fatalf("build estimate result: %v", err)
|
||||
}
|
||||
if result.TotalAmount != 1.25 {
|
||||
t.Fatalf("preferred total=%v, want 1.25", result.TotalAmount)
|
||||
}
|
||||
if result.ReservationAmount != 2.75 {
|
||||
t.Fatalf("reservation=%v, want 2.75", result.ReservationAmount)
|
||||
}
|
||||
if result.CandidateCount != 2 || result.PricingVersion != pricingVersionV2 || result.RequestFingerprint != "fingerprint" {
|
||||
t.Fatalf("unexpected estimate metadata: %+v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPricingAvailabilityRequiresExplicitFree(t *testing.T) {
|
||||
paid := resolvedPricing{Config: map[string]any{"imageBase": 1.5}, Currency: "resource"}
|
||||
if _, err := paid.requiredPrice("image", "imageBase", "basePrice"); err != nil {
|
||||
t.Fatalf("positive price should be available: %v", err)
|
||||
}
|
||||
|
||||
missing := resolvedPricing{Config: map[string]any{}, Currency: "resource"}
|
||||
if _, err := missing.requiredPrice("image", "imageBase", "basePrice"); !isPricingUnavailable(err) {
|
||||
t.Fatalf("missing price should be unavailable, got %v", err)
|
||||
}
|
||||
|
||||
ordinaryZero := resolvedPricing{Config: map[string]any{"imageBase": 0}, Currency: "resource"}
|
||||
if _, err := ordinaryZero.requiredPrice("image", "imageBase", "basePrice"); !isPricingUnavailable(err) {
|
||||
t.Fatalf("ordinary zero should be unavailable, got %v", err)
|
||||
}
|
||||
|
||||
explicitFree := resolvedPricing{
|
||||
Config: map[string]any{"imageBase": 0},
|
||||
Currency: "resource",
|
||||
FreeResource: map[string]bool{"image": true},
|
||||
}
|
||||
price, err := explicitFree.requiredPrice("image", "imageBase", "basePrice")
|
||||
if err != nil || !price.IsZero() {
|
||||
t.Fatalf("explicit free should resolve to zero: price=%s err=%v", price.String(), err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPricingWeightsUseFixedPrecisionAndRejectInvalidValues(t *testing.T) {
|
||||
pricing := resolvedPricing{Config: map[string]any{
|
||||
"image": map[string]any{
|
||||
"dynamicWeight": map[string]any{
|
||||
"qualityFactors": map[string]any{"high": "1.123456789", "broken": 0},
|
||||
},
|
||||
},
|
||||
}}
|
||||
weight, err := pricing.weight("image", "qualityWeights", "high")
|
||||
if err != nil || weight.String() != "1.123456789" {
|
||||
t.Fatalf("exact weight=%s err=%v", weight.String(), err)
|
||||
}
|
||||
if _, err := pricing.weight("image", "qualityWeights", "broken"); !isPricingUnavailable(err) {
|
||||
t.Fatalf("invalid configured weight should make pricing unavailable: %v", err)
|
||||
}
|
||||
defaultWeight, err := pricing.weight("image", "qualityWeights", "unconfigured")
|
||||
if err != nil || defaultWeight.String() != "1.000000000" {
|
||||
t.Fatalf("default weight=%s err=%v", defaultWeight.String(), err)
|
||||
}
|
||||
}
|
||||
|
||||
func mustFixedAmount(t *testing.T, value string) fixedAmount {
|
||||
t.Helper()
|
||||
amount, err := parseFixedAmount(value)
|
||||
if err != nil {
|
||||
t.Fatalf("parse %q: %v", value, err)
|
||||
}
|
||||
return amount
|
||||
}
|
||||
@ -10,7 +10,6 @@ import (
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
||||
"github.com/google/uuid"
|
||||
"github.com/riverqueue/river"
|
||||
"github.com/riverqueue/river/riverdriver/riverpgxv5"
|
||||
"github.com/riverqueue/river/rivermigrate"
|
||||
@ -39,26 +38,16 @@ func (w *asyncTaskWorker) Work(ctx context.Context, job *river.Job[asyncTaskArgs
|
||||
if task.Status == "succeeded" || task.Status == "failed" || task.Status == "cancelled" {
|
||||
return nil
|
||||
}
|
||||
executionToken := uuid.NewString()
|
||||
result, runErr := w.service.executeWithToken(ctx, task, authUserFromTask(task), nil, executionToken)
|
||||
result, runErr := w.service.Execute(ctx, task, authUserFromTask(task))
|
||||
if runErr == nil {
|
||||
w.service.logger.Debug("river async task completed", "taskID", task.ID, "status", result.Task.Status, "riverJobID", job.ID)
|
||||
return nil
|
||||
}
|
||||
if errors.Is(runErr, store.ErrTaskExecutionLeaseUnavailable) {
|
||||
w.service.logger.Debug("river async task execution lease already held", "taskID", task.ID, "riverJobID", job.ID)
|
||||
return nil
|
||||
}
|
||||
if errors.Is(runErr, store.ErrTaskExecutionManualReview) {
|
||||
w.service.logger.Warn("river async task moved to manual review after ambiguous upstream submission", "taskID", task.ID, "riverJobID", job.ID)
|
||||
return nil
|
||||
}
|
||||
var queuedErr *TaskQueuedError
|
||||
if errors.As(runErr, &queuedErr) {
|
||||
return river.JobSnooze(queuedErr.Delay)
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
task.ExecutionToken = executionToken
|
||||
queued, queueErr := w.service.requeueInterruptedAsyncTask(context.WithoutCancel(ctx), task)
|
||||
if queueErr != nil {
|
||||
return queueErr
|
||||
@ -156,7 +145,8 @@ func (s *Service) recoverAsyncRiverJobs(ctx context.Context) error {
|
||||
return err
|
||||
}
|
||||
for _, item := range items {
|
||||
result, err := s.riverClient.Insert(ctx, asyncTaskArgs{TaskID: item.ID}, asyncTaskRecoveryInsertOpts(item))
|
||||
task := store.GatewayTask{ID: item.ID}
|
||||
result, err := s.riverClient.Insert(ctx, asyncTaskArgs{TaskID: item.ID}, asyncTaskInsertOpts(task))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@ -196,16 +186,6 @@ func asyncTaskInsertOpts(task store.GatewayTask) *river.InsertOpts {
|
||||
}
|
||||
}
|
||||
|
||||
func asyncTaskRecoveryInsertOpts(item store.AsyncTaskQueueItem) *river.InsertOpts {
|
||||
opts := asyncTaskInsertOpts(store.GatewayTask{ID: item.ID})
|
||||
opts.ScheduledAt = item.NextRunAt
|
||||
// A replacement process must not be blocked by a River row that the dead
|
||||
// process left in running state. PostgreSQL execution leases still ensure
|
||||
// that only one recovery job can call the upstream provider.
|
||||
opts.UniqueOpts = river.UniqueOpts{}
|
||||
return opts
|
||||
}
|
||||
|
||||
func authUserFromTask(task store.GatewayTask) *auth.User {
|
||||
roles := []string{"user"}
|
||||
if strings.TrimSpace(task.UserID) == "" {
|
||||
|
||||
@ -14,7 +14,6 @@ import (
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/config"
|
||||
scriptengine "github.com/easyai/easyai-ai-gateway/apps/api/internal/script"
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/riverqueue/river"
|
||||
)
|
||||
@ -27,11 +26,6 @@ type Service struct {
|
||||
scriptExecutor *scriptengine.Executor
|
||||
httpClients *httpClientCache
|
||||
riverClient *river.Client[pgx.Tx]
|
||||
billingMetrics billingMetricsObserver
|
||||
}
|
||||
|
||||
type billingMetricsObserver interface {
|
||||
ObserveBillingEvent(string)
|
||||
}
|
||||
|
||||
type Result struct {
|
||||
@ -45,19 +39,6 @@ type TaskQueuedError struct {
|
||||
Delay time.Duration
|
||||
}
|
||||
|
||||
type upstreamSubmissionUnknownError struct {
|
||||
AttemptID string
|
||||
Cause error
|
||||
}
|
||||
|
||||
func (e *upstreamSubmissionUnknownError) Error() string {
|
||||
return "upstream submission result is unknown"
|
||||
}
|
||||
|
||||
func (e *upstreamSubmissionUnknownError) Unwrap() error {
|
||||
return e.Cause
|
||||
}
|
||||
|
||||
func (e *TaskQueuedError) Error() string {
|
||||
return ErrTaskQueued.Error()
|
||||
}
|
||||
@ -66,10 +47,10 @@ func (e *TaskQueuedError) Is(target error) bool {
|
||||
return target == ErrTaskQueued
|
||||
}
|
||||
|
||||
func New(cfg config.Config, db *store.Store, logger *slog.Logger, observers ...billingMetricsObserver) *Service {
|
||||
func New(cfg config.Config, db *store.Store, logger *slog.Logger) *Service {
|
||||
httpClients := newHTTPClientCache()
|
||||
scriptExecutor := &scriptengine.Executor{Logger: logger}
|
||||
service := &Service{
|
||||
return &Service{
|
||||
cfg: cfg,
|
||||
store: db,
|
||||
logger: logger,
|
||||
@ -95,16 +76,6 @@ func New(cfg config.Config, db *store.Store, logger *slog.Logger, observers ...b
|
||||
},
|
||||
httpClients: httpClients,
|
||||
}
|
||||
if len(observers) > 0 {
|
||||
service.billingMetrics = observers[0]
|
||||
}
|
||||
return service
|
||||
}
|
||||
|
||||
func (s *Service) observeBillingEvent(event string) {
|
||||
if s.billingMetrics != nil {
|
||||
s.billingMetrics.ObserveBillingEvent(event)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) Execute(ctx context.Context, task store.GatewayTask, user *auth.User) (Result, error) {
|
||||
@ -116,20 +87,6 @@ func (s *Service) ExecuteStream(ctx context.Context, task store.GatewayTask, use
|
||||
}
|
||||
|
||||
func (s *Service) execute(ctx context.Context, task store.GatewayTask, user *auth.User, onDelta clients.StreamDelta) (Result, error) {
|
||||
return s.executeWithToken(ctx, task, user, onDelta, uuid.NewString())
|
||||
}
|
||||
|
||||
func (s *Service) executeWithToken(ctx context.Context, task store.GatewayTask, user *auth.User, onDelta clients.StreamDelta, executionToken string) (Result, error) {
|
||||
wasRunning := task.Status == "running"
|
||||
claimed, err := s.store.ClaimTaskExecution(ctx, task.ID, executionToken, taskExecutionLeaseTTL)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
task = claimed
|
||||
executionCtx, stopExecution := context.WithCancel(ctx)
|
||||
defer stopExecution()
|
||||
go s.renewTaskExecutionLease(executionCtx, stopExecution, task.ID, task.ExecutionToken)
|
||||
ctx = executionCtx
|
||||
executeStartedAt := time.Now()
|
||||
restoredRequest, err := s.restoreTaskRequestReferences(ctx, task)
|
||||
if err != nil {
|
||||
@ -138,10 +95,10 @@ func (s *Service) executeWithToken(ctx context.Context, task store.GatewayTask,
|
||||
body := normalizeRequest(task.Kind, restoredRequest)
|
||||
responseExecution := responseExecutionContext{}
|
||||
modelType := modelTypeFromKind(task.Kind, body)
|
||||
if err := s.store.MarkTaskRunning(ctx, task.ID, task.ExecutionToken, modelType, s.slimTaskRequestSnapshot(task, body)); err != nil {
|
||||
if err := s.store.MarkTaskRunning(ctx, task.ID, modelType, s.slimTaskRequestSnapshot(task, body)); err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
if !wasRunning {
|
||||
if task.Status != "running" {
|
||||
if err := s.emit(ctx, task.ID, "task.running", "running", "starting", 0.12, "task pulled from queue and started", map[string]any{"modelType": modelType}, task.RunMode == "simulation"); err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
@ -158,7 +115,7 @@ func (s *Service) executeWithToken(ctx context.Context, task store.GatewayTask,
|
||||
Reason: "request_validation_failed",
|
||||
ModelType: modelType,
|
||||
})
|
||||
failed, finishErr := s.failTask(ctx, task.ID, task.ExecutionToken, "bad_request", err.Error(), task.RunMode == "simulation", err)
|
||||
failed, finishErr := s.failTask(ctx, task.ID, "bad_request", err.Error(), task.RunMode == "simulation", err)
|
||||
if finishErr != nil {
|
||||
return Result{}, finishErr
|
||||
}
|
||||
@ -178,14 +135,14 @@ func (s *Service) executeWithToken(ctx context.Context, task store.GatewayTask,
|
||||
Reason: "cloned_voice_binding_failed",
|
||||
ModelType: modelType,
|
||||
})
|
||||
failed, finishErr := s.failTask(ctx, task.ID, task.ExecutionToken, clients.ErrorCode(err), err.Error(), task.RunMode == "simulation", err)
|
||||
failed, finishErr := s.failTask(ctx, task.ID, clients.ErrorCode(err), err.Error(), task.RunMode == "simulation", err)
|
||||
if finishErr != nil {
|
||||
return Result{}, finishErr
|
||||
}
|
||||
return Result{Task: failed, Output: failed.Result}, err
|
||||
}
|
||||
if clonedVoice.Found {
|
||||
if err := s.store.MarkTaskRunning(ctx, task.ID, task.ExecutionToken, modelType, s.slimTaskRequestSnapshot(task, body)); err != nil {
|
||||
if err := s.store.MarkTaskRunning(ctx, task.ID, modelType, s.slimTaskRequestSnapshot(task, body)); err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
}
|
||||
@ -194,7 +151,7 @@ func (s *Service) executeWithToken(ctx context.Context, task store.GatewayTask,
|
||||
if err != nil {
|
||||
code, message := responseExecutionFailure(err)
|
||||
s.recordFailedAttempt(ctx, failedAttemptRecord{Task: task, Body: body, AttemptNo: task.AttemptCount + 1, Code: code, Cause: err, Simulated: task.RunMode == "simulation", Scope: "response_chain", Reason: code, ModelType: modelType})
|
||||
failed, finishErr := s.failTask(ctx, task.ID, task.ExecutionToken, code, message, task.RunMode == "simulation", err)
|
||||
failed, finishErr := s.failTask(ctx, task.ID, code, message, task.RunMode == "simulation", err)
|
||||
if finishErr != nil {
|
||||
return Result{}, finishErr
|
||||
}
|
||||
@ -226,7 +183,7 @@ func (s *Service) executeWithToken(ctx context.Context, task store.GatewayTask,
|
||||
Reason: "candidate_selection_failed",
|
||||
ModelType: modelType,
|
||||
})
|
||||
failed, finishErr := s.failTask(ctx, task.ID, task.ExecutionToken, store.ModelCandidateErrorCode(err), err.Error(), task.RunMode == "simulation", err)
|
||||
failed, finishErr := s.failTask(ctx, task.ID, store.ModelCandidateErrorCode(err), err.Error(), task.RunMode == "simulation", err)
|
||||
if finishErr != nil {
|
||||
return Result{}, finishErr
|
||||
}
|
||||
@ -245,7 +202,7 @@ func (s *Service) executeWithToken(ctx context.Context, task store.GatewayTask,
|
||||
Reason: store.ModelCandidateErrorCode(err),
|
||||
ModelType: modelType,
|
||||
})
|
||||
failed, finishErr := s.failTask(ctx, task.ID, task.ExecutionToken, store.ModelCandidateErrorCode(err), err.Error(), task.RunMode == "simulation", err)
|
||||
failed, finishErr := s.failTask(ctx, task.ID, store.ModelCandidateErrorCode(err), err.Error(), task.RunMode == "simulation", err)
|
||||
if finishErr != nil {
|
||||
return Result{}, finishErr
|
||||
}
|
||||
@ -267,7 +224,7 @@ func (s *Service) executeWithToken(ctx context.Context, task store.GatewayTask,
|
||||
ExtraMetrics: []map[string]any{candidateFilterMetrics},
|
||||
ModelType: modelType,
|
||||
})
|
||||
failed, finishErr := s.failTask(ctx, task.ID, task.ExecutionToken, store.ModelCandidateErrorCode(err), err.Error(), task.RunMode == "simulation", err, candidateFilterMetrics)
|
||||
failed, finishErr := s.failTask(ctx, task.ID, store.ModelCandidateErrorCode(err), err.Error(), task.RunMode == "simulation", err, candidateFilterMetrics)
|
||||
if finishErr != nil {
|
||||
return Result{}, finishErr
|
||||
}
|
||||
@ -283,7 +240,7 @@ func (s *Service) executeWithToken(ctx context.Context, task store.GatewayTask,
|
||||
Simulated: task.RunMode == "simulation", Scope: "candidate_output_token_filter", Reason: store.ModelCandidateErrorCode(err),
|
||||
ExtraMetrics: []map[string]any{candidateFilterMetrics}, ModelType: modelType,
|
||||
})
|
||||
failed, finishErr := s.failTask(ctx, task.ID, task.ExecutionToken, store.ModelCandidateErrorCode(err), err.Error(), task.RunMode == "simulation", err, candidateFilterMetrics)
|
||||
failed, finishErr := s.failTask(ctx, task.ID, store.ModelCandidateErrorCode(err), err.Error(), task.RunMode == "simulation", err, candidateFilterMetrics)
|
||||
if finishErr != nil {
|
||||
return Result{}, finishErr
|
||||
}
|
||||
@ -294,120 +251,13 @@ func (s *Service) executeWithToken(ctx context.Context, task store.GatewayTask,
|
||||
if err != nil {
|
||||
code, message := responseExecutionFailure(err)
|
||||
s.recordFailedAttempt(ctx, failedAttemptRecord{Task: task, Body: body, AttemptNo: task.AttemptCount + 1, Code: code, Cause: err, Simulated: task.RunMode == "simulation", Scope: "response_protocol", Reason: code, ModelType: modelType})
|
||||
failed, finishErr := s.failTask(ctx, task.ID, task.ExecutionToken, code, message, task.RunMode == "simulation", err)
|
||||
failed, finishErr := s.failTask(ctx, task.ID, code, message, task.RunMode == "simulation", err)
|
||||
if finishErr != nil {
|
||||
return Result{}, finishErr
|
||||
}
|
||||
return Result{Task: failed, Output: failed.Result}, err
|
||||
}
|
||||
}
|
||||
pricingByCandidate := map[string]resolvedPricing{}
|
||||
preprocessingByCandidate := map[string]parameterPreprocessResult{}
|
||||
reservationBillings := []any(nil)
|
||||
reservationPricingSnapshot := map[string]any(nil)
|
||||
if task.RunMode == "production" {
|
||||
billingMode := normalizedBillingEngineMode(s.cfg.BillingEngineMode)
|
||||
if billingMode == "hold" {
|
||||
holdErr := &clients.ClientError{Code: "billing_hold", Message: "production billing is temporarily on hold", Retryable: true}
|
||||
failed, finishErr := s.failTask(ctx, task.ID, task.ExecutionToken, "billing_hold", holdErr.Error(), false, holdErr)
|
||||
if finishErr != nil {
|
||||
return Result{}, finishErr
|
||||
}
|
||||
return Result{Task: failed, Output: failed.Result}, holdErr
|
||||
}
|
||||
estimates := make([]candidateEstimate, 0, len(candidates))
|
||||
var pricingErr error
|
||||
var preprocessingErr error
|
||||
var preprocessingCandidate store.RuntimeModelCandidate
|
||||
for _, candidate := range candidates {
|
||||
preprocessing := s.preprocessRequestWithScripts(ctx, task.Kind, body, candidate)
|
||||
preprocessingByCandidate[pricingCandidateKey(candidate)] = preprocessing
|
||||
if preprocessing.Err != nil {
|
||||
preprocessingErr = parameterPreprocessClientError(preprocessing.Err)
|
||||
preprocessingCandidate = candidate
|
||||
break
|
||||
}
|
||||
estimate, estimateErr := s.estimateCandidateV2(ctx, user, task.Kind, preprocessing.Body, candidate)
|
||||
if estimateErr != nil {
|
||||
pricingErr = estimateErr
|
||||
if billingMode == "enforce" {
|
||||
break
|
||||
}
|
||||
continue
|
||||
}
|
||||
estimates = append(estimates, estimate)
|
||||
pricingByCandidate[pricingCandidateKey(candidate)] = estimate.Pricing
|
||||
}
|
||||
if preprocessingErr != nil {
|
||||
preprocessing := preprocessingByCandidate[pricingCandidateKey(preprocessingCandidate)]
|
||||
s.recordFailedAttempt(ctx, failedAttemptRecord{
|
||||
Task: task, Body: preprocessing.Body, Candidate: &preprocessingCandidate,
|
||||
AttemptNo: task.AttemptCount + 1, Code: clients.ErrorCode(preprocessingErr), Cause: preprocessingErr,
|
||||
Simulated: false, Scope: "parameter_preprocessing", Reason: "parameter_preprocessing_failed",
|
||||
ExtraMetrics: []map[string]any{parameterPreprocessingMetrics(preprocessing.Log)}, Preprocessing: &preprocessing.Log,
|
||||
ModelType: preprocessingCandidate.ModelType,
|
||||
})
|
||||
failed, finishErr := s.failTask(ctx, task.ID, task.ExecutionToken, clients.ErrorCode(preprocessingErr), preprocessingErr.Error(), false, preprocessingErr, parameterPreprocessingMetrics(preprocessing.Log))
|
||||
if finishErr != nil {
|
||||
return Result{}, finishErr
|
||||
}
|
||||
return Result{Task: failed, Output: failed.Result}, preprocessingErr
|
||||
}
|
||||
if billingMode == "observe" {
|
||||
legacyItems, legacyAmount := s.maximumLegacyCandidateEstimate(ctx, user, task.Kind, body, candidates, preprocessingByCandidate)
|
||||
reservationBillings = legacyItems
|
||||
candidateSnapshots := make([]any, 0, len(estimates))
|
||||
for _, estimate := range estimates {
|
||||
candidateSnapshots = append(candidateSnapshots, estimate.Snapshot)
|
||||
}
|
||||
observedAmount := ""
|
||||
if maximumEstimate, estimateErr := maximumCandidateEstimate(estimates); estimateErr == nil {
|
||||
observedAmount = maximumEstimate.Amount.String()
|
||||
}
|
||||
reservationPricingSnapshot = map[string]any{
|
||||
"pricingVersion": "legacy-observe", "observedPricingVersion": pricingVersionV2,
|
||||
"requestFingerprint": pricingRequestFingerprint(task.Kind, task.Model, body),
|
||||
"reservationAmount": legacyAmount.String(), "observedReservationAmount": observedAmount,
|
||||
"candidateCount": len(candidates), "observedCandidates": candidateSnapshots,
|
||||
}
|
||||
s.logger.Info("billing observe comparison", "taskID", task.ID, "legacyAmount", legacyAmount.String(), "v2Amount", observedAmount, "pricedCandidates", len(estimates), "candidateCount", len(candidates))
|
||||
} else if pricingErr != nil || len(estimates) != len(candidates) {
|
||||
if pricingErr == nil {
|
||||
pricingErr = &PricingUnavailableError{Reason: "not every candidate has effective pricing"}
|
||||
}
|
||||
s.observeBillingEvent("pricing_unavailable")
|
||||
failed, finishErr := s.failTask(ctx, task.ID, task.ExecutionToken, "pricing_unavailable", pricingErr.Error(), false, pricingErr)
|
||||
if finishErr != nil {
|
||||
return Result{}, finishErr
|
||||
}
|
||||
return Result{Task: failed, Output: failed.Result}, pricingErr
|
||||
} else {
|
||||
maximumEstimate, estimateErr := maximumCandidateEstimate(estimates)
|
||||
if estimateErr != nil {
|
||||
failed, finishErr := s.failTask(ctx, task.ID, task.ExecutionToken, "pricing_unavailable", estimateErr.Error(), false, estimateErr)
|
||||
if finishErr != nil {
|
||||
return Result{}, finishErr
|
||||
}
|
||||
return Result{Task: failed, Output: failed.Result}, estimateErr
|
||||
}
|
||||
pricedCandidates := make([]store.RuntimeModelCandidate, 0, len(estimates))
|
||||
candidateSnapshots := make([]any, 0, len(estimates))
|
||||
for _, candidate := range candidates {
|
||||
if _, ok := pricingByCandidate[pricingCandidateKey(candidate)]; ok {
|
||||
pricedCandidates = append(pricedCandidates, candidate)
|
||||
}
|
||||
}
|
||||
for _, estimate := range estimates {
|
||||
candidateSnapshots = append(candidateSnapshots, estimate.Snapshot)
|
||||
}
|
||||
candidates = pricedCandidates
|
||||
reservationBillings = maximumEstimate.Items
|
||||
reservationPricingSnapshot = map[string]any{
|
||||
"pricingVersion": pricingVersionV2, "requestFingerprint": pricingRequestFingerprint(task.Kind, task.Model, body),
|
||||
"reservationAmount": maximumEstimate.Amount.String(), "candidateCount": len(estimates), "candidates": candidateSnapshots,
|
||||
}
|
||||
}
|
||||
}
|
||||
firstCandidateBody := body
|
||||
normalizedModelType := modelType
|
||||
attemptNo := task.AttemptCount
|
||||
@ -420,10 +270,7 @@ func (s *Service) executeWithToken(ctx context.Context, task store.GatewayTask,
|
||||
}
|
||||
}()
|
||||
if len(candidates) > 0 {
|
||||
preprocessing, ok := preprocessingByCandidate[pricingCandidateKey(candidates[0])]
|
||||
if !ok {
|
||||
preprocessing = s.preprocessRequestWithScripts(ctx, task.Kind, body, candidates[0])
|
||||
}
|
||||
preprocessing := s.preprocessRequestWithScripts(ctx, task.Kind, body, candidates[0])
|
||||
firstCandidateBody = preprocessing.Body
|
||||
firstPreprocessing = preprocessing.Log
|
||||
normalizedModelType = candidates[0].ModelType
|
||||
@ -443,17 +290,18 @@ func (s *Service) executeWithToken(ctx context.Context, task store.GatewayTask,
|
||||
Preprocessing: &firstPreprocessing,
|
||||
ModelType: normalizedModelType,
|
||||
})
|
||||
failed, finishErr := s.failTask(ctx, task.ID, task.ExecutionToken, clients.ErrorCode(clientErr), clientErr.Error(), task.RunMode == "simulation", clientErr, parameterPreprocessingMetrics(firstPreprocessing))
|
||||
failed, finishErr := s.failTask(ctx, task.ID, clients.ErrorCode(clientErr), clientErr.Error(), task.RunMode == "simulation", clientErr, parameterPreprocessingMetrics(firstPreprocessing))
|
||||
if finishErr != nil {
|
||||
return Result{}, finishErr
|
||||
}
|
||||
return Result{Task: failed, Output: failed.Result}, clientErr
|
||||
}
|
||||
if err := s.store.MarkTaskRunning(ctx, task.ID, task.ExecutionToken, candidates[0].ModelType, s.slimTaskRequestSnapshot(task, firstCandidateBody)); err != nil {
|
||||
if err := s.store.MarkTaskRunning(ctx, task.ID, candidates[0].ModelType, s.slimTaskRequestSnapshot(task, firstCandidateBody)); err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
estimatedBillings := s.estimatedBillings(ctx, user, task.Kind, firstCandidateBody, candidates[0])
|
||||
var reserveErr error
|
||||
walletReservations, reserveErr = s.store.ReserveTaskBilling(ctx, task, user, reservationBillings, reservationPricingSnapshot)
|
||||
walletReservations, reserveErr = s.store.ReserveTaskBilling(ctx, task, user, estimatedBillings)
|
||||
if reserveErr != nil {
|
||||
if errors.Is(reserveErr, store.ErrInsufficientWalletBalance) {
|
||||
attemptNo = s.recordFailedAttempt(ctx, failedAttemptRecord{
|
||||
@ -470,7 +318,7 @@ func (s *Service) executeWithToken(ctx context.Context, task store.GatewayTask,
|
||||
Preprocessing: &firstPreprocessing,
|
||||
ModelType: normalizedModelType,
|
||||
})
|
||||
failed, finishErr := s.failTask(ctx, task.ID, task.ExecutionToken, "insufficient_balance", reserveErr.Error(), task.RunMode == "simulation", reserveErr, parameterPreprocessingMetrics(firstPreprocessing))
|
||||
failed, finishErr := s.failTask(ctx, task.ID, "insufficient_balance", reserveErr.Error(), task.RunMode == "simulation", reserveErr, parameterPreprocessingMetrics(firstPreprocessing))
|
||||
if finishErr != nil {
|
||||
return Result{}, finishErr
|
||||
}
|
||||
@ -499,10 +347,7 @@ candidatesLoop:
|
||||
var candidateErr error
|
||||
for clientAttempt := 1; clientAttempt <= clientAttempts; clientAttempt++ {
|
||||
nextAttemptNo := attemptNo + 1
|
||||
preprocessing, ok := preprocessingByCandidate[pricingCandidateKey(candidate)]
|
||||
if !ok {
|
||||
preprocessing = s.preprocessRequestWithScripts(ctx, task.Kind, body, candidate)
|
||||
}
|
||||
preprocessing := s.preprocessRequestWithScripts(ctx, task.Kind, body, candidate)
|
||||
preprocessingLog := preprocessing.Log
|
||||
lastPreprocessing = &preprocessingLog
|
||||
if preprocessing.Err != nil {
|
||||
@ -524,80 +369,47 @@ candidatesLoop:
|
||||
break candidatesLoop
|
||||
}
|
||||
candidateBody := preprocessing.Body
|
||||
candidatePricing := pricingByCandidate[pricingCandidateKey(candidate)]
|
||||
response, err := s.runCandidate(ctx, task, user, candidateBody, preprocessing.Log, candidate, candidatePricing, nextAttemptNo, onDelta, responseExecution, singleSourceProtected, runnerPolicy.CacheAffinityPolicy, cacheAffinityKeys.Record)
|
||||
response, err := s.runCandidate(ctx, task, user, candidateBody, preprocessing.Log, candidate, nextAttemptNo, onDelta, responseExecution, singleSourceProtected, runnerPolicy.CacheAffinityPolicy, cacheAffinityKeys.Record)
|
||||
if err == nil {
|
||||
attemptNo = nextAttemptNo
|
||||
var billings []any
|
||||
finalAmount := fixedAmount(0)
|
||||
finalAmountText := ""
|
||||
pricingSnapshot := map[string]any{"pricingVersion": pricingVersionV2, "source": "simulation"}
|
||||
if task.RunMode == "production" {
|
||||
billingMode := normalizedBillingEngineMode(s.cfg.BillingEngineMode)
|
||||
var billingErr error
|
||||
if billingMode == "observe" {
|
||||
billings = s.billings(ctx, user, task.Kind, candidateBody, candidate, response, false)
|
||||
finalAmount = billingItemsFixedTotal(billings)
|
||||
pricingSnapshot = map[string]any{
|
||||
"pricingVersion": "legacy-observe", "observedPricingVersion": pricingVersionV2,
|
||||
"observedPricing": candidatePricing.Snapshot,
|
||||
}
|
||||
} else {
|
||||
billings, finalAmount, _, billingErr = s.billingsWithResolvedPricingV2(ctx, user, task.Kind, candidateBody, candidate, response, false, candidatePricing)
|
||||
pricingSnapshot = candidatePricing.Snapshot
|
||||
}
|
||||
if billingErr != nil {
|
||||
review, reviewErr := s.store.FinishTaskManualReview(context.WithoutCancel(ctx), store.FinishTaskManualReviewInput{
|
||||
TaskID: task.ID, ExecutionToken: task.ExecutionToken, AttemptID: response.AttemptID, TaskStatus: "succeeded",
|
||||
Code: "billing_calculation_failed", Message: "billing calculation requires manual review",
|
||||
Result: response.Result, RequestID: response.RequestID,
|
||||
PricingSnapshot: candidatePricing.Snapshot,
|
||||
RequestFingerprint: pricingRequestFingerprint(task.Kind, task.Model, candidateBody),
|
||||
ResponseStartedAt: response.ResponseStartedAt, ResponseFinishedAt: response.ResponseFinishedAt,
|
||||
ResponseDurationMS: response.ResponseDurationMS,
|
||||
})
|
||||
if reviewErr != nil {
|
||||
return Result{}, reviewErr
|
||||
}
|
||||
walletReservationFinalized = true
|
||||
s.logger.Warn("task succeeded but billing requires manual review", "taskID", task.ID, "error_category", "billing_calculation_failed")
|
||||
return Result{Task: review, Output: response.Result}, nil
|
||||
}
|
||||
finalAmountText = finalAmount.String()
|
||||
} else {
|
||||
billings = s.billings(ctx, user, task.Kind, candidateBody, candidate, response, true)
|
||||
}
|
||||
billings := s.billings(ctx, user, task.Kind, candidateBody, candidate, response, isSimulation(task, candidate))
|
||||
record := buildSuccessRecord(task, user, candidateBody, candidate, response, billings, isSimulation(task, candidate))
|
||||
record.Metrics = mergeMetrics(record.Metrics, candidateCapabilityFilterMetrics(candidateFilterSummary))
|
||||
record.Metrics = mergeMetrics(record.Metrics, parameterPreprocessingMetrics(preprocessing.Log))
|
||||
record.Metrics = s.withAttemptHistory(ctx, task.ID, record.Metrics)
|
||||
finished, finishErr := s.store.FinishTaskSuccess(ctx, store.FinishTaskSuccessInput{
|
||||
TaskID: task.ID,
|
||||
ExecutionToken: task.ExecutionToken,
|
||||
AttemptID: response.AttemptID,
|
||||
Result: response.Result,
|
||||
Billings: billings,
|
||||
RequestID: record.RequestID,
|
||||
ResolvedModel: record.ResolvedModel,
|
||||
Usage: record.Usage,
|
||||
Metrics: record.Metrics,
|
||||
BillingSummary: record.BillingSummary,
|
||||
FinalChargeAmount: record.FinalChargeAmount,
|
||||
FinalChargeAmountText: finalAmountText,
|
||||
BillingCurrency: stringFromAny(record.BillingSummary["currency"]),
|
||||
PricingSnapshot: pricingSnapshot,
|
||||
RequestFingerprint: pricingRequestFingerprint(task.Kind, task.Model, candidateBody),
|
||||
ResponseStartedAt: record.ResponseStartedAt,
|
||||
ResponseFinishedAt: record.ResponseFinishedAt,
|
||||
ResponseDurationMS: record.ResponseDurationMS,
|
||||
TaskID: task.ID,
|
||||
Result: response.Result,
|
||||
Billings: billings,
|
||||
RequestID: record.RequestID,
|
||||
ResolvedModel: record.ResolvedModel,
|
||||
Usage: record.Usage,
|
||||
Metrics: record.Metrics,
|
||||
BillingSummary: record.BillingSummary,
|
||||
FinalChargeAmount: record.FinalChargeAmount,
|
||||
ResponseStartedAt: record.ResponseStartedAt,
|
||||
ResponseFinishedAt: record.ResponseFinishedAt,
|
||||
ResponseDurationMS: record.ResponseDurationMS,
|
||||
})
|
||||
if finishErr != nil {
|
||||
return Result{}, finishErr
|
||||
}
|
||||
if finished.FinalChargeAmount > 0 {
|
||||
walletReservationFinalized = true
|
||||
if settleErr := s.store.SettleTaskBilling(ctx, finished); settleErr != nil {
|
||||
return Result{}, settleErr
|
||||
}
|
||||
} else if len(walletReservations) > 0 {
|
||||
if releaseErr := s.store.ReleaseTaskBillingReservations(ctx, walletReservations, "task_billing_zero"); releaseErr != nil {
|
||||
return Result{}, releaseErr
|
||||
}
|
||||
walletReservationFinalized = true
|
||||
}
|
||||
walletReservationFinalized = true
|
||||
if finished.BillingStatus == "pending" {
|
||||
if err := s.emit(ctx, task.ID, "task.billing.pending", "succeeded", "billing", 0.98, "task billing queued", map[string]any{
|
||||
"amount": finished.FinalChargeAmount, "currency": finished.BillingCurrency,
|
||||
if finished.FinalChargeAmount > 0 {
|
||||
if err := s.emit(ctx, task.ID, "task.billing.settled", "succeeded", "billing", 0.98, "task billing settled", map[string]any{
|
||||
"amount": finished.FinalChargeAmount,
|
||||
"currency": stringFromAny(record.BillingSummary["currency"]),
|
||||
}, isSimulation(task, candidate)); err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
@ -614,21 +426,6 @@ candidatesLoop:
|
||||
}
|
||||
return Result{Task: finished, Output: response.Result}, nil
|
||||
}
|
||||
var submissionUnknown *upstreamSubmissionUnknownError
|
||||
if errors.As(err, &submissionUnknown) {
|
||||
review, reviewErr := s.store.FinishTaskManualReview(context.WithoutCancel(ctx), store.FinishTaskManualReviewInput{
|
||||
TaskID: task.ID, ExecutionToken: task.ExecutionToken, AttemptID: submissionUnknown.AttemptID, TaskStatus: "failed",
|
||||
Code: "upstream_submission_unknown", Message: submissionUnknown.Error(),
|
||||
PricingSnapshot: candidatePricing.Snapshot,
|
||||
RequestFingerprint: pricingRequestFingerprint(task.Kind, task.Model, candidateBody),
|
||||
})
|
||||
if reviewErr != nil {
|
||||
return Result{}, reviewErr
|
||||
}
|
||||
walletReservationFinalized = true
|
||||
s.logger.Warn("upstream submission requires manual review", "taskID", task.ID, "attemptID", submissionUnknown.AttemptID, "error_category", "upstream_submission_unknown")
|
||||
return Result{Task: review, Output: review.Result}, submissionUnknown
|
||||
}
|
||||
if isLocalRateLimitError(err) {
|
||||
lastErr = err
|
||||
candidateErr = err
|
||||
@ -776,66 +573,14 @@ candidatesLoop:
|
||||
if lastPreprocessing != nil {
|
||||
extraMetrics = append(extraMetrics, parameterPreprocessingMetrics(*lastPreprocessing))
|
||||
}
|
||||
failed, err := s.failTask(ctx, task.ID, task.ExecutionToken, code, message, task.RunMode == "simulation", lastErr, extraMetrics...)
|
||||
failed, err := s.failTask(ctx, task.ID, code, message, task.RunMode == "simulation", lastErr, extraMetrics...)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
// FinishTaskFailure atomically transfers ownership of any reservation to the release Outbox.
|
||||
walletReservationFinalized = true
|
||||
return Result{Task: failed, Output: failed.Result}, lastErr
|
||||
}
|
||||
|
||||
func pricingCandidateKey(candidate store.RuntimeModelCandidate) string {
|
||||
return firstNonEmptyString(candidate.PlatformModelID, candidate.PlatformID+":"+candidate.ModelName)
|
||||
}
|
||||
|
||||
func normalizedBillingEngineMode(value string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "enforce", "hold":
|
||||
return strings.ToLower(strings.TrimSpace(value))
|
||||
default:
|
||||
return "observe"
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) maximumLegacyCandidateEstimate(ctx context.Context, user *auth.User, kind string, body map[string]any, candidates []store.RuntimeModelCandidate, preprocessingByCandidate map[string]parameterPreprocessResult) ([]any, fixedAmount) {
|
||||
var maximumItems []any
|
||||
maximumAmount := fixedAmount(0)
|
||||
for index, candidate := range candidates {
|
||||
candidateBody := preprocessRequest(kind, cloneMap(body), candidate)
|
||||
if preprocessing, ok := preprocessingByCandidate[pricingCandidateKey(candidate)]; ok && preprocessing.Err == nil {
|
||||
candidateBody = preprocessing.Body
|
||||
}
|
||||
items := s.estimatedBillings(ctx, user, kind, candidateBody, candidate)
|
||||
amount := billingItemsFixedTotal(items)
|
||||
if index == 0 || amount > maximumAmount {
|
||||
maximumItems = items
|
||||
maximumAmount = amount
|
||||
}
|
||||
}
|
||||
return maximumItems, maximumAmount
|
||||
}
|
||||
|
||||
func billingItemsFixedTotal(items []any) fixedAmount {
|
||||
total := fixedAmount(0)
|
||||
for _, raw := range items {
|
||||
line, _ := raw.(map[string]any)
|
||||
if line == nil {
|
||||
continue
|
||||
}
|
||||
amount, err := fixedAmountFromAny(line["amount"])
|
||||
if err == nil && amount > 0 {
|
||||
next, addErr := addFixedAmounts(total, amount)
|
||||
if addErr != nil {
|
||||
return maxFixedAmount
|
||||
}
|
||||
total = next
|
||||
}
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
func (s *Service) runCandidate(ctx context.Context, task store.GatewayTask, user *auth.User, body map[string]any, preprocessing parameterPreprocessingLog, candidate store.RuntimeModelCandidate, pricing resolvedPricing, attemptNo int, onDelta clients.StreamDelta, responseExecution responseExecutionContext, singleSourceProtected bool, cacheAffinityPolicy map[string]any, cacheAffinityRecordKeys []string) (clients.Response, error) {
|
||||
func (s *Service) runCandidate(ctx context.Context, task store.GatewayTask, user *auth.User, body map[string]any, preprocessing parameterPreprocessingLog, candidate store.RuntimeModelCandidate, attemptNo int, onDelta clients.StreamDelta, responseExecution responseExecutionContext, singleSourceProtected bool, cacheAffinityPolicy map[string]any, cacheAffinityRecordKeys []string) (clients.Response, error) {
|
||||
simulated := isSimulation(task, candidate)
|
||||
baseAttemptMetrics := mergeMetrics(attemptMetrics(candidate, attemptNo, simulated), parameterPreprocessingMetrics(preprocessing))
|
||||
reservations := s.rateLimitReservations(ctx, user, candidate, body)
|
||||
@ -854,18 +599,16 @@ func (s *Service) runCandidate(ctx context.Context, task store.GatewayTask, user
|
||||
defer s.store.ReleaseConcurrencyLeases(context.WithoutCancel(ctx), limitResult.LeaseIDs)
|
||||
|
||||
attemptID, err := s.store.CreateTaskAttempt(ctx, store.CreateTaskAttemptInput{
|
||||
TaskID: task.ID,
|
||||
AttemptNo: attemptNo,
|
||||
PlatformID: candidate.PlatformID,
|
||||
PlatformModelID: candidate.PlatformModelID,
|
||||
ClientID: candidate.ClientID,
|
||||
QueueKey: candidate.QueueKey,
|
||||
Status: "running",
|
||||
Simulated: simulated,
|
||||
RequestSnapshot: s.slimTaskRequestSnapshot(task, body),
|
||||
Metrics: baseAttemptMetrics,
|
||||
PricingSnapshot: pricing.Snapshot,
|
||||
RequestFingerprint: pricingRequestFingerprint(task.Kind, task.Model, body),
|
||||
TaskID: task.ID,
|
||||
AttemptNo: attemptNo,
|
||||
PlatformID: candidate.PlatformID,
|
||||
PlatformModelID: candidate.PlatformModelID,
|
||||
ClientID: candidate.ClientID,
|
||||
QueueKey: candidate.QueueKey,
|
||||
Status: "running",
|
||||
Simulated: simulated,
|
||||
RequestSnapshot: s.slimTaskRequestSnapshot(task, body),
|
||||
Metrics: baseAttemptMetrics,
|
||||
})
|
||||
if err != nil {
|
||||
return clients.Response{}, fmt.Errorf("create task attempt: %w", err)
|
||||
@ -935,9 +678,6 @@ func (s *Service) runCandidate(ctx context.Context, task store.GatewayTask, user
|
||||
publicResponseID = responseExecution.PublicResponseID
|
||||
publicPreviousResponseID = responseExecution.PublicPreviousResponseID
|
||||
}
|
||||
if err := s.store.SetAttemptUpstreamSubmissionStatus(ctx, attemptID, "submitting"); err != nil {
|
||||
return clients.Response{}, fmt.Errorf("mark upstream submission: %w", err)
|
||||
}
|
||||
response, err := client.Run(ctx, clients.Request{
|
||||
Kind: task.Kind,
|
||||
ModelType: candidate.ModelType,
|
||||
@ -951,7 +691,7 @@ func (s *Service) runCandidate(ctx context.Context, task store.GatewayTask, user
|
||||
if strings.TrimSpace(remoteTaskID) == "" {
|
||||
return nil
|
||||
}
|
||||
return s.store.SetTaskRemoteTask(context.WithoutCancel(ctx), task.ID, task.ExecutionToken, attemptID, remoteTaskID, payload)
|
||||
return s.store.SetTaskRemoteTask(context.WithoutCancel(ctx), task.ID, attemptID, remoteTaskID, payload)
|
||||
},
|
||||
Stream: boolFromMap(providerBody, "stream"),
|
||||
StreamDelta: onDelta,
|
||||
@ -962,11 +702,6 @@ func (s *Service) runCandidate(ctx context.Context, task store.GatewayTask, user
|
||||
PreviousResponseTurns: responseExecution.PreviousTurns,
|
||||
})
|
||||
callFinishedAt := time.Now()
|
||||
if err == nil {
|
||||
if markErr := s.store.SetAttemptUpstreamSubmissionStatus(context.WithoutCancel(ctx), attemptID, "response_received"); markErr != nil {
|
||||
return clients.Response{}, &upstreamSubmissionUnknownError{AttemptID: attemptID, Cause: markErr}
|
||||
}
|
||||
}
|
||||
if response.ResponseStartedAt.IsZero() {
|
||||
response.ResponseStartedAt = callStartedAt
|
||||
}
|
||||
@ -980,11 +715,6 @@ func (s *Service) runCandidate(ctx context.Context, task store.GatewayTask, user
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
if clients.ErrorResponseMetadata(err).StatusCode > 0 {
|
||||
if markErr := s.store.SetAttemptUpstreamSubmissionStatus(context.WithoutCancel(ctx), attemptID, "response_received"); markErr != nil {
|
||||
return clients.Response{}, &upstreamSubmissionUnknownError{AttemptID: attemptID, Cause: markErr}
|
||||
}
|
||||
}
|
||||
retryable := clients.IsRetryable(err)
|
||||
requestID, metrics, responseStartedAt, responseFinishedAt, responseDurationMS := failureMetrics(err, simulated)
|
||||
if responseStartedAt.IsZero() {
|
||||
@ -1013,9 +743,6 @@ func (s *Service) runCandidate(ctx context.Context, task store.GatewayTask, user
|
||||
ErrorMessage: err.Error(),
|
||||
})
|
||||
_ = s.emit(ctx, task.ID, "task.attempt.failed", "running", "attempt_failed", 0.45, err.Error(), map[string]any{"attempt": attemptNo, "retryable": retryable, "requestId": requestID, "statusCode": clients.ErrorResponseMetadata(err).StatusCode, "metrics": metrics}, simulated)
|
||||
if !simulated && clients.ErrorResponseMetadata(err).StatusCode == 0 {
|
||||
return clients.Response{}, &upstreamSubmissionUnknownError{AttemptID: attemptID, Cause: err}
|
||||
}
|
||||
s.applyCandidateFailurePolicies(ctx, task.ID, candidate, err, simulated, singleSourceProtected)
|
||||
return clients.Response{}, err
|
||||
}
|
||||
@ -1097,7 +824,19 @@ func (s *Service) runCandidate(ctx context.Context, task store.GatewayTask, user
|
||||
return clients.Response{}, fmt.Errorf("commit rate limit reservations: %w", err)
|
||||
}
|
||||
rateReservationsFinalized = true
|
||||
response.AttemptID = attemptID
|
||||
if err := s.store.FinishTaskAttempt(ctx, store.FinishTaskAttemptInput{
|
||||
AttemptID: attemptID,
|
||||
Status: "succeeded",
|
||||
RequestID: response.RequestID,
|
||||
Usage: usageToMap(response.Usage),
|
||||
Metrics: mergeMetrics(taskMetrics(task, user, body, candidate, response, simulated), parameterPreprocessingMetrics(preprocessing)),
|
||||
ResponseSnapshot: response.Result,
|
||||
ResponseStartedAt: response.ResponseStartedAt,
|
||||
ResponseFinishedAt: response.ResponseFinishedAt,
|
||||
ResponseDurationMS: response.ResponseDurationMS,
|
||||
}); err != nil {
|
||||
return clients.Response{}, fmt.Errorf("finish task attempt: %w", err)
|
||||
}
|
||||
if err := s.store.RecordCacheAffinityObservation(context.WithoutCancel(ctx), store.CacheAffinityObservationInput{
|
||||
CacheAffinityKey: candidate.CacheAffinity.Key,
|
||||
CacheAffinityKeys: cacheAffinityRecordKeys,
|
||||
@ -1165,7 +904,7 @@ func (s *Service) clientFor(candidate store.RuntimeModelCandidate, simulated boo
|
||||
return s.clients["openai"]
|
||||
}
|
||||
|
||||
func (s *Service) failTask(ctx context.Context, taskID string, executionToken string, code string, message string, simulated bool, cause error, extraMetrics ...map[string]any) (store.GatewayTask, error) {
|
||||
func (s *Service) failTask(ctx context.Context, taskID string, code string, message string, simulated bool, cause error, extraMetrics ...map[string]any) (store.GatewayTask, error) {
|
||||
requestID, metrics, responseStartedAt, responseFinishedAt, responseDurationMS := failureMetrics(cause, simulated)
|
||||
if len(extraMetrics) > 0 {
|
||||
values := append([]map[string]any{metrics}, extraMetrics...)
|
||||
@ -1174,7 +913,6 @@ func (s *Service) failTask(ctx context.Context, taskID string, executionToken st
|
||||
metrics = s.withAttemptHistory(ctx, taskID, metrics)
|
||||
failed, err := s.store.FinishTaskFailure(ctx, store.FinishTaskFailureInput{
|
||||
TaskID: taskID,
|
||||
ExecutionToken: executionToken,
|
||||
Code: code,
|
||||
Message: message,
|
||||
Result: buildFailureResult(code, message, requestID, cause),
|
||||
@ -1303,7 +1041,7 @@ func (s *Service) requeueRateLimitedTask(ctx context.Context, task store.Gateway
|
||||
if delay <= 0 {
|
||||
delay = 5 * time.Second
|
||||
}
|
||||
queued, err := s.store.RequeueTask(ctx, task.ID, task.ExecutionToken, delay, candidate.QueueKey)
|
||||
queued, err := s.store.RequeueTask(ctx, task.ID, delay, candidate.QueueKey)
|
||||
if err != nil {
|
||||
return store.GatewayTask{}, 0, err
|
||||
}
|
||||
@ -1319,7 +1057,7 @@ func (s *Service) requeueRateLimitedTask(ctx context.Context, task store.Gateway
|
||||
}
|
||||
|
||||
func (s *Service) requeueInterruptedAsyncTask(ctx context.Context, task store.GatewayTask) (store.GatewayTask, error) {
|
||||
queued, err := s.store.RequeueTask(ctx, task.ID, task.ExecutionToken, 0, "")
|
||||
queued, err := s.store.RequeueTask(ctx, task.ID, 0, "")
|
||||
if err != nil {
|
||||
return store.GatewayTask{}, err
|
||||
}
|
||||
|
||||
@ -182,20 +182,6 @@ func TestExecuteWithMockClientRejectsConcurrentTasksBeyondWalletBalance(t *testi
|
||||
if got := mockClient.calls.Load(); got != 1 {
|
||||
t.Fatalf("mock client calls = %d, want 1", got)
|
||||
}
|
||||
settlements, err := db.ClaimBillingSettlements(ctx, "wallet-execute-test", store.BillingSettlementBatchSize, store.BillingSettlementLockTimeout)
|
||||
if err != nil {
|
||||
t.Fatalf("claim billing settlements: %v", err)
|
||||
}
|
||||
processed := 0
|
||||
for _, settlement := range settlements {
|
||||
if err := db.ProcessBillingSettlement(ctx, settlement); err != nil {
|
||||
t.Fatalf("process billing settlement %s: %v", settlement.ID, err)
|
||||
}
|
||||
processed++
|
||||
}
|
||||
if processed != 1 {
|
||||
t.Fatalf("processed billing settlements = %d, want 1", processed)
|
||||
}
|
||||
|
||||
summary, err := db.GetWalletSummary(ctx, user, "resource")
|
||||
if err != nil {
|
||||
|
||||
@ -18,32 +18,25 @@ type MetricsSnapshotProvider interface {
|
||||
type DynamicMetricsSnapshotProvider interface {
|
||||
MetricsSnapshotProvider
|
||||
SecurityEventConnection(context.Context) (store.SecurityEventConnection, error)
|
||||
BillingMetrics(context.Context) (store.BillingMetricsSnapshot, error)
|
||||
}
|
||||
|
||||
type Metrics struct {
|
||||
accepted atomic.Uint64
|
||||
rejected atomic.Uint64
|
||||
duplicate atomic.Uint64
|
||||
sessionsDeleted atomic.Uint64
|
||||
watermarkRejected atomic.Uint64
|
||||
verificationAccepted atomic.Uint64
|
||||
heartbeatAccepted atomic.Uint64
|
||||
heartbeatFailed atomic.Uint64
|
||||
introspectionActive atomic.Uint64
|
||||
introspectionInactive atomic.Uint64
|
||||
introspectionFailed atomic.Uint64
|
||||
jwksSSFFailed atomic.Uint64
|
||||
jwksOIDCFailed atomic.Uint64
|
||||
processingCount atomic.Uint64
|
||||
processingNanos atomic.Uint64
|
||||
processingBuckets [6]atomic.Uint64
|
||||
billingSettlementCompleted atomic.Uint64
|
||||
billingSettlementRetry atomic.Uint64
|
||||
billingManualReview atomic.Uint64
|
||||
billingEstimateFailed atomic.Uint64
|
||||
billingIdempotentReplay atomic.Uint64
|
||||
billingPricingUnavailable atomic.Uint64
|
||||
accepted atomic.Uint64
|
||||
rejected atomic.Uint64
|
||||
duplicate atomic.Uint64
|
||||
sessionsDeleted atomic.Uint64
|
||||
watermarkRejected atomic.Uint64
|
||||
verificationAccepted atomic.Uint64
|
||||
heartbeatAccepted atomic.Uint64
|
||||
heartbeatFailed atomic.Uint64
|
||||
introspectionActive atomic.Uint64
|
||||
introspectionInactive atomic.Uint64
|
||||
introspectionFailed atomic.Uint64
|
||||
jwksSSFFailed atomic.Uint64
|
||||
jwksOIDCFailed atomic.Uint64
|
||||
processingCount atomic.Uint64
|
||||
processingNanos atomic.Uint64
|
||||
processingBuckets [6]atomic.Uint64
|
||||
}
|
||||
|
||||
var processingDurationBounds = [...]time.Duration{
|
||||
@ -106,23 +99,6 @@ func (m *Metrics) ObserveJWKSRefreshFailure(source string) {
|
||||
m.jwksOIDCFailed.Add(1)
|
||||
}
|
||||
|
||||
func (m *Metrics) ObserveBillingEvent(event string) {
|
||||
switch event {
|
||||
case "settlement_completed":
|
||||
m.billingSettlementCompleted.Add(1)
|
||||
case "settlement_retry":
|
||||
m.billingSettlementRetry.Add(1)
|
||||
case "manual_review":
|
||||
m.billingManualReview.Add(1)
|
||||
case "estimate_failed":
|
||||
m.billingEstimateFailed.Add(1)
|
||||
case "idempotent_replay":
|
||||
m.billingIdempotentReplay.Add(1)
|
||||
case "pricing_unavailable":
|
||||
m.billingPricingUnavailable.Add(1)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Metrics) Handler(provider MetricsSnapshotProvider, issuer, audience string, enabled bool) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
mode := "disabled"
|
||||
@ -137,17 +113,6 @@ func (m *Metrics) Handler(provider MetricsSnapshotProvider, issuer, audience str
|
||||
return
|
||||
}
|
||||
}
|
||||
billing := store.BillingMetricsSnapshot{}
|
||||
if billingProvider, ok := provider.(interface {
|
||||
BillingMetrics(context.Context) (store.BillingMetricsSnapshot, error)
|
||||
}); ok {
|
||||
var err error
|
||||
billing, err = billingProvider.BillingMetrics(r.Context())
|
||||
if err != nil {
|
||||
http.Error(w, "metrics unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8")
|
||||
outcomeCounters(w, "easyai_gateway_ssf_receipts_total", "Received SETs by bounded outcome.", []outcomeValue{
|
||||
{"accepted", m.accepted.Load()}, {"rejected", m.rejected.Load()}, {"duplicate", m.duplicate.Load()},
|
||||
@ -191,27 +156,6 @@ func (m *Metrics) Handler(provider MetricsSnapshotProvider, issuer, audience str
|
||||
}
|
||||
fmt.Fprintf(w, "easyai_gateway_ssf_mode{mode=\"%s\"} %d\n", modeName, value)
|
||||
}
|
||||
fmt.Fprintln(w, "# HELP easyai_gateway_billing_settlement_backlog Current unsettled Outbox records.")
|
||||
fmt.Fprintln(w, "# TYPE easyai_gateway_billing_settlement_backlog gauge")
|
||||
fmt.Fprintf(w, "easyai_gateway_billing_settlement_backlog %d\n", billing.SettlementBacklog)
|
||||
fmt.Fprintln(w, "# HELP easyai_gateway_billing_settlement_delay_seconds Age of the oldest unsettled Outbox record.")
|
||||
fmt.Fprintln(w, "# TYPE easyai_gateway_billing_settlement_delay_seconds gauge")
|
||||
fmt.Fprintf(w, "easyai_gateway_billing_settlement_delay_seconds %.6f\n", billing.SettlementDelaySecs)
|
||||
fmt.Fprintln(w, "# HELP easyai_gateway_billing_manual_review Current billing records requiring manual review.")
|
||||
fmt.Fprintln(w, "# TYPE easyai_gateway_billing_manual_review gauge")
|
||||
fmt.Fprintf(w, "easyai_gateway_billing_manual_review %d\n", billing.ManualReview)
|
||||
fmt.Fprintln(w, "# HELP easyai_gateway_billing_orphan_frozen Wallets whose frozen amount differs from active reservations.")
|
||||
fmt.Fprintln(w, "# TYPE easyai_gateway_billing_orphan_frozen gauge")
|
||||
fmt.Fprintf(w, "easyai_gateway_billing_orphan_frozen %d\n", billing.OrphanFrozen)
|
||||
fmt.Fprintln(w, "# HELP easyai_gateway_billing_pricing_unavailable_tasks Current tasks rejected because no effective price was available.")
|
||||
fmt.Fprintln(w, "# TYPE easyai_gateway_billing_pricing_unavailable_tasks gauge")
|
||||
fmt.Fprintf(w, "easyai_gateway_billing_pricing_unavailable_tasks %d\n", billing.PricingUnavailable)
|
||||
plainCounter(w, "easyai_gateway_billing_settlements_completed_total", "Billing settlements completed by this process.", m.billingSettlementCompleted.Load())
|
||||
plainCounter(w, "easyai_gateway_billing_settlement_retries_total", "Billing settlement retries scheduled by this process.", m.billingSettlementRetry.Load())
|
||||
plainCounter(w, "easyai_gateway_billing_manual_review_transitions_total", "Billing records transitioned to manual review by this process.", m.billingManualReview.Load())
|
||||
plainCounter(w, "easyai_gateway_billing_estimate_failures_total", "Pricing estimate requests that failed.", m.billingEstimateFailed.Load())
|
||||
plainCounter(w, "easyai_gateway_billing_idempotent_replays_total", "Generation requests replayed idempotently.", m.billingIdempotentReplay.Load())
|
||||
plainCounter(w, "easyai_gateway_billing_pricing_unavailable_total", "Pricing requests rejected because no effective price was available.", m.billingPricingUnavailable.Load())
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@ -1,637 +0,0 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
const (
|
||||
BillingSettlementBatchSize = 50
|
||||
BillingSettlementLockTimeout = 120 * time.Second
|
||||
BillingSettlementMaxAttempts = 20
|
||||
)
|
||||
|
||||
var ErrBillingSettlementNotRetryable = errors.New("billing settlement is not retryable")
|
||||
|
||||
type BillingSettlement struct {
|
||||
ID string `json:"id"`
|
||||
TaskID string `json:"taskId"`
|
||||
Action string `json:"action"`
|
||||
Amount float64 `json:"amount"`
|
||||
Currency string `json:"currency"`
|
||||
Status string `json:"status"`
|
||||
Attempts int `json:"attempts"`
|
||||
NextAttemptAt time.Time `json:"nextAttemptAt"`
|
||||
LockedBy string `json:"lockedBy,omitempty"`
|
||||
LockedAt string `json:"lockedAt,omitempty"`
|
||||
LastErrorCode string `json:"lastErrorCode,omitempty"`
|
||||
LastErrorMessage string `json:"lastErrorMessage,omitempty"`
|
||||
ManualReviewReason string `json:"manualReviewReason,omitempty"`
|
||||
PricingSnapshot map[string]any `json:"pricingSnapshot,omitempty"`
|
||||
Payload map[string]any `json:"payload,omitempty"`
|
||||
CompletedAt string `json:"completedAt,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
LockToken string `json:"-"`
|
||||
amountExact string
|
||||
}
|
||||
|
||||
type BillingSettlementListFilter struct {
|
||||
Status string
|
||||
Action string
|
||||
Page int
|
||||
PageSize int
|
||||
}
|
||||
|
||||
type BillingSettlementListResult struct {
|
||||
Items []BillingSettlement `json:"items"`
|
||||
Total int `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"pageSize"`
|
||||
}
|
||||
|
||||
type BillingMetricsSnapshot struct {
|
||||
SettlementBacklog int64
|
||||
SettlementDelaySecs float64
|
||||
ManualReview int64
|
||||
OrphanFrozen int64
|
||||
PricingUnavailable int64
|
||||
}
|
||||
|
||||
func (s *Store) BillingMetrics(ctx context.Context) (BillingMetricsSnapshot, error) {
|
||||
var snapshot BillingMetricsSnapshot
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
WITH open_outbox AS (
|
||||
SELECT created_at
|
||||
FROM settlement_outbox
|
||||
WHERE status IN ('pending', 'processing', 'retryable_failed')
|
||||
), active_reservations AS (
|
||||
SELECT reserve.account_id, COALESCE(SUM(reserve.amount), 0) AS amount
|
||||
FROM gateway_wallet_transactions reserve
|
||||
WHERE reserve.transaction_type = 'reserve'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM gateway_wallet_transactions release
|
||||
WHERE release.account_id = reserve.account_id
|
||||
AND release.transaction_type = 'release'
|
||||
AND release.idempotency_key = reserve.idempotency_key || ':release'
|
||||
)
|
||||
GROUP BY reserve.account_id
|
||||
)
|
||||
SELECT
|
||||
(SELECT COUNT(*) FROM open_outbox),
|
||||
COALESCE((SELECT EXTRACT(EPOCH FROM now() - MIN(created_at)) FROM open_outbox), 0),
|
||||
(SELECT COUNT(*) FROM gateway_tasks WHERE billing_status = 'manual_review'),
|
||||
(SELECT COUNT(*) FROM gateway_wallet_accounts account
|
||||
LEFT JOIN active_reservations reservation ON reservation.account_id = account.id
|
||||
WHERE account.frozen_balance <> COALESCE(reservation.amount, 0)),
|
||||
(SELECT COUNT(*) FROM gateway_tasks WHERE error_code = 'pricing_unavailable')`).Scan(
|
||||
&snapshot.SettlementBacklog,
|
||||
&snapshot.SettlementDelaySecs,
|
||||
&snapshot.ManualReview,
|
||||
&snapshot.OrphanFrozen,
|
||||
&snapshot.PricingUnavailable,
|
||||
)
|
||||
return snapshot, err
|
||||
}
|
||||
|
||||
func (s *Store) ClaimBillingSettlements(ctx context.Context, workerID string, limit int, staleAfter time.Duration) ([]BillingSettlement, error) {
|
||||
if limit <= 0 || limit > BillingSettlementBatchSize {
|
||||
limit = BillingSettlementBatchSize
|
||||
}
|
||||
if staleAfter <= 0 {
|
||||
staleAfter = BillingSettlementLockTimeout
|
||||
}
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
WITH picked AS (
|
||||
SELECT id
|
||||
FROM settlement_outbox
|
||||
WHERE (
|
||||
status IN ('pending', 'retryable_failed')
|
||||
AND next_attempt_at <= now()
|
||||
)
|
||||
OR (
|
||||
status = 'processing'
|
||||
AND locked_at < now() - ($3::int * interval '1 second')
|
||||
)
|
||||
ORDER BY next_attempt_at ASC, created_at ASC
|
||||
LIMIT $2
|
||||
FOR UPDATE SKIP LOCKED
|
||||
)
|
||||
UPDATE settlement_outbox outbox
|
||||
SET status = 'processing',
|
||||
attempts = outbox.attempts + 1,
|
||||
locked_by = $1,
|
||||
lock_token = gen_random_uuid(),
|
||||
locked_at = now(),
|
||||
updated_at = now()
|
||||
FROM picked
|
||||
WHERE outbox.id = picked.id
|
||||
RETURNING outbox.id::text, outbox.task_id::text, outbox.action, outbox.amount::text,
|
||||
outbox.currency, outbox.status, outbox.attempts, outbox.next_attempt_at,
|
||||
COALESCE(outbox.locked_by, ''), COALESCE(outbox.lock_token::text, ''),
|
||||
COALESCE(outbox.locked_at::text, ''), COALESCE(outbox.last_error_code, ''),
|
||||
COALESCE(outbox.last_error_message, ''), COALESCE(outbox.manual_review_reason, ''),
|
||||
outbox.pricing_snapshot, outbox.payload, COALESCE(outbox.completed_at::text, ''),
|
||||
outbox.created_at, outbox.updated_at`,
|
||||
workerID, limit, int(staleAfter/time.Second))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := make([]BillingSettlement, 0)
|
||||
for rows.Next() {
|
||||
item, err := scanBillingSettlement(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) ProcessBillingSettlement(ctx context.Context, settlement BillingSettlement) error {
|
||||
return pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
|
||||
var action string
|
||||
var amount string
|
||||
var currency string
|
||||
var taskID string
|
||||
var taskStatus string
|
||||
var gatewayUserID string
|
||||
var gatewayTenantID string
|
||||
var userID string
|
||||
var tenantID string
|
||||
var tenantKey string
|
||||
err := tx.QueryRow(ctx, `
|
||||
SELECT outbox.action, outbox.amount::text, outbox.currency, outbox.task_id::text,
|
||||
task.status, COALESCE(task.gateway_user_id::text, ''),
|
||||
COALESCE(task.gateway_tenant_id::text, ''), task.user_id,
|
||||
COALESCE(task.tenant_id, ''), COALESCE(task.tenant_key, '')
|
||||
FROM settlement_outbox outbox
|
||||
JOIN gateway_tasks task ON task.id = outbox.task_id
|
||||
WHERE outbox.id = $1::uuid
|
||||
AND outbox.status = 'processing'
|
||||
AND outbox.lock_token = $2::uuid
|
||||
FOR UPDATE OF outbox, task`, settlement.ID, settlement.LockToken).Scan(
|
||||
&action, &amount, ¤cy, &taskID, &taskStatus, &gatewayUserID,
|
||||
&gatewayTenantID, &userID, &tenantID, &tenantKey,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE gateway_tasks
|
||||
SET billing_status = 'processing', billing_updated_at = now(), updated_at = now()
|
||||
WHERE id = $1::uuid`, taskID); err != nil {
|
||||
return err
|
||||
}
|
||||
switch action {
|
||||
case "settle":
|
||||
if taskStatus != "succeeded" {
|
||||
return fmt.Errorf("settle action requires succeeded task")
|
||||
}
|
||||
if err := settleBillingOutboxTx(ctx, tx, taskID, gatewayUserID, gatewayTenantID, userID, tenantID, tenantKey, currency, amount); err != nil {
|
||||
return err
|
||||
}
|
||||
return completeBillingOutboxTx(ctx, tx, settlement.ID, settlement.LockToken, taskID, "settled")
|
||||
case "release":
|
||||
if err := releaseBillingOutboxTx(ctx, tx, taskID, gatewayUserID, gatewayTenantID, currency); err != nil {
|
||||
return err
|
||||
}
|
||||
return completeBillingOutboxTx(ctx, tx, settlement.ID, settlement.LockToken, taskID, "released")
|
||||
default:
|
||||
return fmt.Errorf("unsupported billing settlement action %q", action)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func settleBillingOutboxTx(
|
||||
ctx context.Context,
|
||||
tx pgx.Tx,
|
||||
taskID string,
|
||||
gatewayUserID string,
|
||||
gatewayTenantID string,
|
||||
userID string,
|
||||
tenantID string,
|
||||
tenantKey string,
|
||||
currency string,
|
||||
amount string,
|
||||
) error {
|
||||
if gatewayUserID == "" {
|
||||
return fmt.Errorf("task %s has no gateway wallet user", taskID)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO gateway_wallet_accounts (
|
||||
gateway_tenant_id, gateway_user_id, tenant_id, tenant_key, user_id, currency
|
||||
)
|
||||
VALUES (NULLIF($1, '')::uuid, $2::uuid, NULLIF($3, ''), NULLIF($4, ''), NULLIF($5, ''), $6)
|
||||
ON CONFLICT (gateway_user_id, currency) DO NOTHING`,
|
||||
gatewayTenantID, gatewayUserID, tenantID, tenantKey, userID, currency); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ensureWalletAccountAuditGuard(ctx, tx, gatewayUserID, currency); err != nil {
|
||||
return err
|
||||
}
|
||||
var accountID string
|
||||
var balanceBefore string
|
||||
var frozenBefore string
|
||||
if err := tx.QueryRow(ctx, `
|
||||
SELECT id::text, balance::text, frozen_balance::text
|
||||
FROM gateway_wallet_accounts
|
||||
WHERE gateway_user_id = $1::uuid AND currency = $2
|
||||
FOR UPDATE`, gatewayUserID, currency).Scan(&accountID, &balanceBefore, &frozenBefore); err != nil {
|
||||
return err
|
||||
}
|
||||
var alreadySettled bool
|
||||
if err := tx.QueryRow(ctx, `
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM gateway_wallet_transactions
|
||||
WHERE account_id = $1::uuid AND idempotency_key = $2
|
||||
)`, accountID, billingIdempotencyKey(taskID)).Scan(&alreadySettled); err != nil {
|
||||
return err
|
||||
}
|
||||
if alreadySettled {
|
||||
return nil
|
||||
}
|
||||
|
||||
var reservationKey string
|
||||
var reservedAmount string
|
||||
if err := tx.QueryRow(ctx, `
|
||||
SELECT COALESCE(reserve.idempotency_key, ''), reserve.amount::text
|
||||
FROM gateway_wallet_transactions reserve
|
||||
WHERE reserve.account_id = $1::uuid
|
||||
AND reserve.reference_type = 'gateway_task'
|
||||
AND reserve.reference_id = $2
|
||||
AND reserve.transaction_type = 'reserve'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM gateway_wallet_transactions release
|
||||
WHERE release.account_id = reserve.account_id
|
||||
AND release.transaction_type = 'release'
|
||||
AND release.idempotency_key = reserve.idempotency_key || ':release'
|
||||
)
|
||||
ORDER BY reserve.created_at DESC
|
||||
LIMIT 1`, accountID, taskID).Scan(&reservationKey, &reservedAmount); errors.Is(err, pgx.ErrNoRows) {
|
||||
reservationKey = ""
|
||||
reservedAmount = "0"
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var balanceAfter string
|
||||
var frozenAfter string
|
||||
err := tx.QueryRow(ctx, `
|
||||
UPDATE gateway_wallet_accounts
|
||||
SET balance = balance - $2::numeric,
|
||||
total_spent = total_spent + $2::numeric,
|
||||
frozen_balance = GREATEST(0, frozen_balance - $3::numeric),
|
||||
updated_at = now()
|
||||
WHERE id = $1::uuid
|
||||
AND balance - frozen_balance + $3::numeric >= $2::numeric
|
||||
RETURNING balance::text, frozen_balance::text`,
|
||||
accountID, amount, reservedAmount).Scan(&balanceAfter, &frozenAfter)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return fmt.Errorf("%w: task %s settlement amount exceeds spendable balance", ErrInsufficientWalletBalance, taskID)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var reservedPositive bool
|
||||
if err := tx.QueryRow(ctx, `SELECT $1::numeric > 0`, reservedAmount).Scan(&reservedPositive); err != nil {
|
||||
return err
|
||||
}
|
||||
if reservedPositive {
|
||||
releaseMetadata, _ := json.Marshal(map[string]any{
|
||||
"taskId": taskID, "reason": "task_billing_settled",
|
||||
"reserved": reservedAmount, "frozenBefore": frozenBefore, "frozenAfter": frozenAfter,
|
||||
})
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO gateway_wallet_transactions (
|
||||
account_id, gateway_tenant_id, gateway_user_id, direction, transaction_type,
|
||||
amount, balance_before, balance_after, idempotency_key, reference_type, reference_id, metadata
|
||||
)
|
||||
VALUES (
|
||||
$1::uuid, NULLIF($2, '')::uuid, $3::uuid, 'credit', 'release',
|
||||
$4::numeric, $5::numeric, $5::numeric, $6, 'gateway_task', $7, $8::jsonb
|
||||
)
|
||||
ON CONFLICT (account_id, idempotency_key) WHERE idempotency_key IS NOT NULL DO NOTHING`,
|
||||
accountID, gatewayTenantID, gatewayUserID, reservedAmount, balanceBefore,
|
||||
billingReservationReleaseIdempotencyKey(reservationKey), taskID, string(releaseMetadata)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
metadata, _ := json.Marshal(map[string]any{
|
||||
"taskId": taskID, "reservedAmount": reservedAmount,
|
||||
"frozenBefore": frozenBefore, "frozenAfter": frozenAfter,
|
||||
})
|
||||
_, err = tx.Exec(ctx, `
|
||||
INSERT INTO gateway_wallet_transactions (
|
||||
account_id, gateway_tenant_id, gateway_user_id, direction, transaction_type,
|
||||
amount, balance_before, balance_after, idempotency_key, reference_type, reference_id, metadata
|
||||
)
|
||||
VALUES (
|
||||
$1::uuid, NULLIF($2, '')::uuid, $3::uuid, 'debit', 'task_billing',
|
||||
$4::numeric, $5::numeric, $6::numeric, $7, 'gateway_task', $8, $9::jsonb
|
||||
)
|
||||
ON CONFLICT (account_id, idempotency_key) WHERE idempotency_key IS NOT NULL DO NOTHING`,
|
||||
accountID, gatewayTenantID, gatewayUserID, amount, balanceBefore, balanceAfter,
|
||||
billingIdempotencyKey(taskID), taskID, string(metadata))
|
||||
return err
|
||||
}
|
||||
|
||||
func releaseBillingOutboxTx(ctx context.Context, tx pgx.Tx, taskID string, gatewayUserID string, gatewayTenantID string, currency string) error {
|
||||
if gatewayUserID == "" {
|
||||
return nil
|
||||
}
|
||||
var accountID string
|
||||
var balance string
|
||||
var frozenBefore string
|
||||
err := tx.QueryRow(ctx, `
|
||||
SELECT id::text, balance::text, frozen_balance::text
|
||||
FROM gateway_wallet_accounts
|
||||
WHERE gateway_user_id = $1::uuid AND currency = $2
|
||||
FOR UPDATE`, gatewayUserID, currency).Scan(&accountID, &balance, &frozenBefore)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var reservationKey string
|
||||
var reservedAmount string
|
||||
err = tx.QueryRow(ctx, `
|
||||
SELECT COALESCE(reserve.idempotency_key, ''), reserve.amount::text
|
||||
FROM gateway_wallet_transactions reserve
|
||||
WHERE reserve.account_id = $1::uuid
|
||||
AND reserve.reference_type = 'gateway_task'
|
||||
AND reserve.reference_id = $2
|
||||
AND reserve.transaction_type = 'reserve'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM gateway_wallet_transactions release
|
||||
WHERE release.account_id = reserve.account_id
|
||||
AND release.transaction_type = 'release'
|
||||
AND release.idempotency_key = reserve.idempotency_key || ':release'
|
||||
)
|
||||
ORDER BY reserve.created_at DESC
|
||||
LIMIT 1`, accountID, taskID).Scan(&reservationKey, &reservedAmount)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var frozenAfter string
|
||||
if err := tx.QueryRow(ctx, `
|
||||
UPDATE gateway_wallet_accounts
|
||||
SET frozen_balance = frozen_balance - $2::numeric,
|
||||
updated_at = now()
|
||||
WHERE id = $1::uuid
|
||||
AND frozen_balance >= $2::numeric
|
||||
RETURNING frozen_balance::text`, accountID, reservedAmount).Scan(&frozenAfter); err != nil {
|
||||
return err
|
||||
}
|
||||
metadata, _ := json.Marshal(map[string]any{
|
||||
"taskId": taskID, "reason": "task_terminal_release",
|
||||
"reserved": reservedAmount, "frozenBefore": frozenBefore, "frozenAfter": frozenAfter,
|
||||
})
|
||||
_, err = tx.Exec(ctx, `
|
||||
INSERT INTO gateway_wallet_transactions (
|
||||
account_id, gateway_tenant_id, gateway_user_id, direction, transaction_type,
|
||||
amount, balance_before, balance_after, idempotency_key, reference_type, reference_id, metadata
|
||||
)
|
||||
VALUES (
|
||||
$1::uuid, NULLIF($2, '')::uuid, $3::uuid, 'credit', 'release',
|
||||
$4::numeric, $5::numeric, $5::numeric, $6, 'gateway_task', $7, $8::jsonb
|
||||
)
|
||||
ON CONFLICT (account_id, idempotency_key) WHERE idempotency_key IS NOT NULL DO NOTHING`,
|
||||
accountID, gatewayTenantID, gatewayUserID, reservedAmount, balance,
|
||||
billingReservationReleaseIdempotencyKey(reservationKey), taskID, string(metadata))
|
||||
return err
|
||||
}
|
||||
|
||||
func completeBillingOutboxTx(ctx context.Context, tx pgx.Tx, settlementID string, lockToken string, taskID string, billingStatus string) error {
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE gateway_tasks
|
||||
SET billing_status = $2,
|
||||
reservation_amount = 0,
|
||||
billing_updated_at = now(),
|
||||
billing_settled_at = CASE WHEN $2 = 'settled' THEN now() ELSE billing_settled_at END,
|
||||
updated_at = now()
|
||||
WHERE id = $1::uuid`, taskID, billingStatus); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO gateway_task_events (task_id, seq, event_type, status, phase, progress, message, payload, simulated)
|
||||
SELECT task.id,
|
||||
COALESCE((SELECT MAX(event.seq) + 1 FROM gateway_task_events event WHERE event.task_id = task.id), 1),
|
||||
CASE WHEN $2 = 'settled' THEN 'task.billing.settled' ELSE 'task.billing.released' END,
|
||||
task.status,
|
||||
'billing',
|
||||
1,
|
||||
CASE WHEN $2 = 'settled' THEN 'task billing settled' ELSE 'task billing reservation released' END,
|
||||
jsonb_build_object('settlementId', $3::text, 'billingStatus', $2::text),
|
||||
task.run_mode = 'simulation'
|
||||
FROM gateway_tasks task
|
||||
WHERE task.id = $1::uuid`, taskID, billingStatus, settlementID); err != nil {
|
||||
return err
|
||||
}
|
||||
tag, err := tx.Exec(ctx, `
|
||||
UPDATE settlement_outbox
|
||||
SET status = 'completed',
|
||||
completed_at = now(),
|
||||
locked_by = NULL,
|
||||
lock_token = NULL,
|
||||
locked_at = NULL,
|
||||
last_error_code = NULL,
|
||||
last_error_message = NULL,
|
||||
updated_at = now()
|
||||
WHERE id = $1::uuid
|
||||
AND status = 'processing'
|
||||
AND lock_token = $2::uuid`, settlementID, lockToken)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() != 1 {
|
||||
return pgx.ErrNoRows
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) MarkBillingSettlementFailed(ctx context.Context, settlement BillingSettlement, code string, message string, delay time.Duration) error {
|
||||
if delay < time.Second {
|
||||
delay = time.Second
|
||||
}
|
||||
if delay > 15*time.Minute {
|
||||
delay = 15 * time.Minute
|
||||
}
|
||||
manualReview := settlement.Attempts >= BillingSettlementMaxAttempts
|
||||
status := "retryable_failed"
|
||||
taskStatus := "retryable_failed"
|
||||
manualReason := ""
|
||||
if manualReview {
|
||||
status = "manual_review"
|
||||
taskStatus = "manual_review"
|
||||
manualReason = "maximum settlement attempts exceeded"
|
||||
}
|
||||
return pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
|
||||
tag, err := tx.Exec(ctx, `
|
||||
UPDATE settlement_outbox
|
||||
SET status = $3,
|
||||
next_attempt_at = now() + ($4::int * interval '1 second'),
|
||||
locked_by = NULL,
|
||||
lock_token = NULL,
|
||||
locked_at = NULL,
|
||||
last_error_code = NULLIF($5, ''),
|
||||
last_error_message = NULLIF($6, ''),
|
||||
manual_review_reason = NULLIF($7, ''),
|
||||
updated_at = now()
|
||||
WHERE id = $1::uuid
|
||||
AND lock_token = $2::uuid
|
||||
AND status = 'processing'`,
|
||||
settlement.ID, settlement.LockToken, status, int(delay/time.Second), code, message, manualReason)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() != 1 {
|
||||
return pgx.ErrNoRows
|
||||
}
|
||||
_, err = tx.Exec(ctx, `
|
||||
UPDATE gateway_tasks
|
||||
SET billing_status = $2,
|
||||
billing_updated_at = now(),
|
||||
updated_at = now()
|
||||
WHERE id = $1::uuid`, settlement.TaskID, taskStatus)
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Store) ListBillingSettlements(ctx context.Context, filter BillingSettlementListFilter) (BillingSettlementListResult, error) {
|
||||
page := filter.Page
|
||||
if page <= 0 {
|
||||
page = 1
|
||||
}
|
||||
pageSize := filter.PageSize
|
||||
if pageSize <= 0 {
|
||||
pageSize = 50
|
||||
}
|
||||
if pageSize > 100 {
|
||||
pageSize = 100
|
||||
}
|
||||
status := strings.TrimSpace(filter.Status)
|
||||
action := strings.TrimSpace(filter.Action)
|
||||
var total int
|
||||
if err := s.pool.QueryRow(ctx, `
|
||||
SELECT count(*)
|
||||
FROM settlement_outbox
|
||||
WHERE (NULLIF($1, '') IS NULL OR status = $1)
|
||||
AND (NULLIF($2, '') IS NULL OR action = $2)`, status, action).Scan(&total); err != nil {
|
||||
return BillingSettlementListResult{}, err
|
||||
}
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT id::text, task_id::text, action, amount::text, currency, status, attempts,
|
||||
next_attempt_at, COALESCE(locked_by, ''), COALESCE(lock_token::text, ''),
|
||||
COALESCE(locked_at::text, ''), COALESCE(last_error_code, ''),
|
||||
COALESCE(last_error_message, ''), COALESCE(manual_review_reason, ''),
|
||||
pricing_snapshot, payload, COALESCE(completed_at::text, ''), created_at, updated_at
|
||||
FROM settlement_outbox
|
||||
WHERE (NULLIF($1, '') IS NULL OR status = $1)
|
||||
AND (NULLIF($2, '') IS NULL OR action = $2)
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $3 OFFSET $4`, status, action, pageSize, (page-1)*pageSize)
|
||||
if err != nil {
|
||||
return BillingSettlementListResult{}, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := make([]BillingSettlement, 0)
|
||||
for rows.Next() {
|
||||
item, err := scanBillingSettlement(rows)
|
||||
if err != nil {
|
||||
return BillingSettlementListResult{}, err
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return BillingSettlementListResult{}, err
|
||||
}
|
||||
return BillingSettlementListResult{Items: items, Total: total, Page: page, PageSize: pageSize}, nil
|
||||
}
|
||||
|
||||
func (s *Store) RetryBillingSettlementTx(ctx context.Context, tx Tx, id string, idempotencyKeyHash string) (BillingSettlement, bool, error) {
|
||||
current, err := scanBillingSettlement(tx.QueryRow(ctx, `
|
||||
SELECT id::text, task_id::text, action, amount::text, currency, status, attempts,
|
||||
next_attempt_at, COALESCE(locked_by, ''), COALESCE(lock_token::text, ''),
|
||||
COALESCE(locked_at::text, ''), COALESCE(last_error_code, ''),
|
||||
COALESCE(last_error_message, ''), COALESCE(manual_review_reason, ''),
|
||||
pricing_snapshot, payload, COALESCE(completed_at::text, ''), created_at, updated_at
|
||||
FROM settlement_outbox
|
||||
WHERE id = $1::uuid
|
||||
FOR UPDATE`, id))
|
||||
if err != nil {
|
||||
return BillingSettlement{}, false, err
|
||||
}
|
||||
var recordedHash string
|
||||
if err := tx.QueryRow(ctx, `
|
||||
SELECT COALESCE(retry_idempotency_key_hash, '')
|
||||
FROM settlement_outbox
|
||||
WHERE id = $1::uuid`, id).Scan(&recordedHash); err != nil {
|
||||
return BillingSettlement{}, false, err
|
||||
}
|
||||
if recordedHash != "" && recordedHash == idempotencyKeyHash {
|
||||
return current, true, nil
|
||||
}
|
||||
if current.Status != "retryable_failed" && current.Status != "manual_review" {
|
||||
return BillingSettlement{}, false, ErrBillingSettlementNotRetryable
|
||||
}
|
||||
item, err := scanBillingSettlement(tx.QueryRow(ctx, `
|
||||
UPDATE settlement_outbox
|
||||
SET status = 'pending',
|
||||
next_attempt_at = now(),
|
||||
locked_by = NULL,
|
||||
lock_token = NULL,
|
||||
locked_at = NULL,
|
||||
manual_review_reason = NULL,
|
||||
retry_idempotency_key_hash = $2,
|
||||
retry_requested_at = now(),
|
||||
updated_at = now()
|
||||
WHERE id = $1::uuid
|
||||
AND status IN ('retryable_failed', 'manual_review')
|
||||
RETURNING id::text, task_id::text, action, amount::text, currency, status, attempts,
|
||||
next_attempt_at, COALESCE(locked_by, ''), COALESCE(lock_token::text, ''),
|
||||
COALESCE(locked_at::text, ''), COALESCE(last_error_code, ''),
|
||||
COALESCE(last_error_message, ''), COALESCE(manual_review_reason, ''),
|
||||
pricing_snapshot, payload, COALESCE(completed_at::text, ''), created_at, updated_at`, id, idempotencyKeyHash))
|
||||
if err != nil {
|
||||
return BillingSettlement{}, false, err
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE gateway_tasks
|
||||
SET billing_status = 'pending', billing_updated_at = now(), updated_at = now()
|
||||
WHERE id = $1::uuid`, item.TaskID); err != nil {
|
||||
return BillingSettlement{}, false, err
|
||||
}
|
||||
return item, false, nil
|
||||
}
|
||||
|
||||
func scanBillingSettlement(scanner taskScanner) (BillingSettlement, error) {
|
||||
var item BillingSettlement
|
||||
var pricingSnapshotBytes []byte
|
||||
var payloadBytes []byte
|
||||
if err := scanner.Scan(
|
||||
&item.ID, &item.TaskID, &item.Action, &item.amountExact, &item.Currency,
|
||||
&item.Status, &item.Attempts, &item.NextAttemptAt, &item.LockedBy, &item.LockToken,
|
||||
&item.LockedAt, &item.LastErrorCode, &item.LastErrorMessage, &item.ManualReviewReason,
|
||||
&pricingSnapshotBytes, &payloadBytes, &item.CompletedAt, &item.CreatedAt, &item.UpdatedAt,
|
||||
); err != nil {
|
||||
return BillingSettlement{}, err
|
||||
}
|
||||
item.Amount, _ = strconv.ParseFloat(item.amountExact, 64)
|
||||
item.PricingSnapshot = decodeObject(pricingSnapshotBytes)
|
||||
item.Payload = decodeObject(payloadBytes)
|
||||
return item, nil
|
||||
}
|
||||
@ -1,408 +0,0 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
func TestTaskIdempotencyAndExecutionLease(t *testing.T) {
|
||||
db := billingV2IntegrationStore(t)
|
||||
ctx := context.Background()
|
||||
tenantID, gatewayUserID := seedWalletReservationUser(t, ctx, db)
|
||||
user := &auth.User{ID: "billing-v2-" + uuid.NewString(), GatewayUserID: gatewayUserID, GatewayTenantID: tenantID}
|
||||
input := CreateTaskInput{
|
||||
Kind: "images.generations", Model: "billing-v2-model", RunMode: "simulation",
|
||||
Request: map[string]any{"model": "billing-v2-model", "prompt": "lease"},
|
||||
IdempotencyKeyHash: "key-hash-" + uuid.NewString(), IdempotencyRequestHash: "request-a",
|
||||
}
|
||||
created, err := db.CreateTaskIdempotent(ctx, input, user)
|
||||
if err != nil || created.Replayed {
|
||||
t.Fatalf("create task result=%+v err=%v", created, err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = db.pool.Exec(context.Background(), `DELETE FROM gateway_tasks WHERE id=$1::uuid`, created.Task.ID)
|
||||
})
|
||||
|
||||
replayed, err := db.CreateTaskIdempotent(ctx, input, user)
|
||||
if err != nil || !replayed.Replayed || replayed.Task.ID != created.Task.ID {
|
||||
t.Fatalf("replay result=%+v err=%v", replayed, err)
|
||||
}
|
||||
input.IdempotencyRequestHash = "request-b"
|
||||
if _, err := db.CreateTaskIdempotent(ctx, input, user); !errors.Is(err, ErrIdempotencyKeyReused) {
|
||||
t.Fatalf("different request error=%v", err)
|
||||
}
|
||||
|
||||
firstToken := uuid.NewString()
|
||||
claimed, err := db.ClaimTaskExecution(ctx, created.Task.ID, firstToken, 5*time.Minute)
|
||||
if err != nil || claimed.ExecutionToken != firstToken {
|
||||
t.Fatalf("first claim task=%+v err=%v", claimed, err)
|
||||
}
|
||||
if err := db.RenewTaskExecutionLease(ctx, created.Task.ID, firstToken, 5*time.Minute); err != nil {
|
||||
t.Fatalf("renew first lease: %v", err)
|
||||
}
|
||||
if _, err := db.pool.Exec(ctx, `UPDATE gateway_tasks SET execution_lease_expires_at=now()-interval '1 second' WHERE id=$1::uuid`, created.Task.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
secondToken := uuid.NewString()
|
||||
if _, err := db.ClaimTaskExecution(ctx, created.Task.ID, secondToken, 5*time.Minute); err != nil {
|
||||
t.Fatalf("take over expired lease: %v", err)
|
||||
}
|
||||
if _, err := db.FinishTaskFailure(ctx, FinishTaskFailureInput{TaskID: created.Task.ID, ExecutionToken: firstToken, Code: "old_worker", Message: "old"}); !errors.Is(err, ErrTaskExecutionLeaseLost) {
|
||||
t.Fatalf("old worker terminal error=%v", err)
|
||||
}
|
||||
finished, err := db.FinishTaskFailure(ctx, FinishTaskFailureInput{TaskID: created.Task.ID, ExecutionToken: secondToken, Code: "new_worker", Message: "new"})
|
||||
if err != nil || finished.ErrorCode != "new_worker" {
|
||||
t.Fatalf("new worker terminal task=%+v err=%v", finished, err)
|
||||
}
|
||||
if err := db.RenewTaskExecutionLease(ctx, created.Task.ID, secondToken, 5*time.Minute); !errors.Is(err, ErrTaskExecutionFinished) {
|
||||
t.Fatalf("terminal task renewal error=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpiredExecutionLeaseDoesNotReplayAmbiguousUpstreamSubmission(t *testing.T) {
|
||||
db := billingV2IntegrationStore(t)
|
||||
ctx := context.Background()
|
||||
tenantID, gatewayUserID := seedWalletReservationUser(t, ctx, db)
|
||||
user := &auth.User{ID: "billing-review-" + uuid.NewString(), GatewayUserID: gatewayUserID, GatewayTenantID: tenantID}
|
||||
created, err := db.CreateTask(ctx, CreateTaskInput{
|
||||
Kind: "images.generations", Model: "billing-v2-model", RunMode: "production",
|
||||
Request: map[string]any{"model": "billing-v2-model"},
|
||||
}, user)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = db.pool.Exec(context.Background(), `DELETE FROM gateway_tasks WHERE id=$1::uuid`, created.ID)
|
||||
})
|
||||
|
||||
firstToken := uuid.NewString()
|
||||
if _, err := db.ClaimTaskExecution(ctx, created.ID, firstToken, 5*time.Minute); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
attemptID, err := db.CreateTaskAttempt(ctx, CreateTaskAttemptInput{
|
||||
TaskID: created.ID, AttemptNo: 1, Status: "running", RequestSnapshot: map[string]any{"model": created.Model},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.SetAttemptUpstreamSubmissionStatus(ctx, attemptID, "submitting"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := db.pool.Exec(ctx, `UPDATE gateway_tasks SET execution_lease_expires_at=now()-interval '1 second' WHERE id=$1::uuid`, created.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := db.ClaimTaskExecution(ctx, created.ID, uuid.NewString(), 5*time.Minute); !errors.Is(err, ErrTaskExecutionManualReview) {
|
||||
t.Fatalf("ambiguous submission takeover error=%v", err)
|
||||
}
|
||||
|
||||
review, err := db.GetTask(ctx, created.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if review.Status != "failed" || review.BillingStatus != "manual_review" || review.ErrorCode != "upstream_submission_unknown" {
|
||||
t.Fatalf("manual review task=%+v", review)
|
||||
}
|
||||
var outboxStatus string
|
||||
var outboxAction string
|
||||
var reviewReason string
|
||||
if err := db.pool.QueryRow(ctx, `
|
||||
SELECT status, action, COALESCE(manual_review_reason, '')
|
||||
FROM settlement_outbox
|
||||
WHERE task_id=$1::uuid AND event_type='task.billing.review'`, created.ID).Scan(&outboxStatus, &outboxAction, &reviewReason); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if outboxStatus != "manual_review" || outboxAction != "release" || reviewReason != "upstream_submission_unknown" {
|
||||
t.Fatalf("review outbox status=%s action=%s reason=%s", outboxStatus, outboxAction, reviewReason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpiredExecutionLeaseCanResumeAfterKnownRejectedResponse(t *testing.T) {
|
||||
db := billingV2IntegrationStore(t)
|
||||
ctx := context.Background()
|
||||
_, gatewayUserID := seedWalletReservationUser(t, ctx, db)
|
||||
user := &auth.User{ID: "billing-known-response-" + uuid.NewString(), GatewayUserID: gatewayUserID}
|
||||
created, err := db.CreateTask(ctx, CreateTaskInput{
|
||||
Kind: "images.generations", Model: "billing-v2-model", RunMode: "production",
|
||||
Request: map[string]any{"model": "billing-v2-model"},
|
||||
}, user)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = db.pool.Exec(context.Background(), `DELETE FROM gateway_tasks WHERE id=$1::uuid`, created.ID)
|
||||
})
|
||||
|
||||
if _, err := db.ClaimTaskExecution(ctx, created.ID, uuid.NewString(), 5*time.Minute); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
attemptID, err := db.CreateTaskAttempt(ctx, CreateTaskAttemptInput{TaskID: created.ID, AttemptNo: 1, Status: "running"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.SetAttemptUpstreamSubmissionStatus(ctx, attemptID, "response_received"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.FinishTaskAttempt(ctx, FinishTaskAttemptInput{AttemptID: attemptID, Status: "failed", ErrorCode: "upstream_rejected"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := db.pool.Exec(ctx, `UPDATE gateway_tasks SET execution_lease_expires_at=now()-interval '1 second' WHERE id=$1::uuid`, created.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := db.ClaimTaskExecution(ctx, created.ID, uuid.NewString(), 5*time.Minute); err != nil {
|
||||
t.Fatalf("known rejected response should remain retryable: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFinishTaskManualReviewCreatesVisibleBillingRecord(t *testing.T) {
|
||||
db := billingV2IntegrationStore(t)
|
||||
ctx := context.Background()
|
||||
_, gatewayUserID := seedWalletReservationUser(t, ctx, db)
|
||||
user := &auth.User{ID: "billing-direct-review-" + uuid.NewString(), GatewayUserID: gatewayUserID}
|
||||
created, err := db.CreateTask(ctx, CreateTaskInput{
|
||||
Kind: "images.generations", Model: "billing-v2-model", RunMode: "production",
|
||||
Request: map[string]any{"model": "billing-v2-model"},
|
||||
}, user)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = db.pool.Exec(context.Background(), `DELETE FROM gateway_tasks WHERE id=$1::uuid`, created.ID)
|
||||
})
|
||||
token := uuid.NewString()
|
||||
if _, err := db.ClaimTaskExecution(ctx, created.ID, token, 5*time.Minute); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
attemptID, err := db.CreateTaskAttempt(ctx, CreateTaskAttemptInput{TaskID: created.ID, AttemptNo: 1, Status: "running"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.SetAttemptUpstreamSubmissionStatus(ctx, attemptID, "submitting"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := db.FinishTaskManualReview(ctx, FinishTaskManualReviewInput{
|
||||
TaskID: created.ID, ExecutionToken: token, AttemptID: attemptID, TaskStatus: "failed",
|
||||
Code: "upstream_submission_unknown", Message: "upstream submission result is unknown",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var visible bool
|
||||
if err := db.pool.QueryRow(ctx, `
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM settlement_outbox
|
||||
WHERE task_id=$1::uuid AND status='manual_review'
|
||||
AND action='release' AND manual_review_reason='upstream_submission_unknown'
|
||||
)`, created.ID).Scan(&visible); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !visible {
|
||||
t.Fatal("manual review billing record is not visible in settlement outbox")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBillingSettlementStaleTakeoverDebitsExactlyOnce(t *testing.T) {
|
||||
db := billingV2IntegrationStore(t)
|
||||
ctx := context.Background()
|
||||
tenantID, gatewayUserID := seedWalletReservationUser(t, ctx, db)
|
||||
user := &auth.User{ID: "billing-settle-" + uuid.NewString(), GatewayUserID: gatewayUserID, GatewayTenantID: tenantID}
|
||||
if _, err := db.SetUserWalletBalance(ctx, WalletBalanceAdjustmentInput{GatewayUserID: gatewayUserID, Currency: "resource", Balance: 10, Reason: "billing v2 test"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
created, err := db.CreateTask(ctx, CreateTaskInput{Kind: "images.generations", Model: "billing-v2-model", RunMode: "production", Request: map[string]any{"model": "billing-v2-model"}}, user)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
cleanupCtx := context.Background()
|
||||
_, _ = db.pool.Exec(cleanupCtx, `DELETE FROM gateway_wallet_transactions WHERE gateway_user_id=$1::uuid`, gatewayUserID)
|
||||
_, _ = db.pool.Exec(cleanupCtx, `DELETE FROM gateway_tasks WHERE id=$1::uuid`, created.ID)
|
||||
_, _ = db.pool.Exec(cleanupCtx, `DELETE FROM gateway_wallet_accounts WHERE gateway_user_id=$1::uuid`, gatewayUserID)
|
||||
})
|
||||
token := uuid.NewString()
|
||||
claimed, err := db.ClaimTaskExecution(ctx, created.ID, token, 5*time.Minute)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
amount := "1.000000001"
|
||||
reservations, err := db.ReserveTaskBilling(ctx, claimed, user, []any{map[string]any{"currency": "resource", "amount": amount}}, map[string]any{
|
||||
"pricingVersion": "effective-pricing-v2", "reservationAmount": amount, "currency": "resource", "requestFingerprint": "billing-v2-test",
|
||||
})
|
||||
if err != nil || len(reservations) != 1 {
|
||||
t.Fatalf("reserve=%+v err=%v", reservations, err)
|
||||
}
|
||||
if _, err := db.FinishTaskSuccess(ctx, FinishTaskSuccessInput{
|
||||
TaskID: created.ID, ExecutionToken: token, Result: map[string]any{"ok": true},
|
||||
FinalChargeAmountText: amount, BillingCurrency: "resource",
|
||||
PricingSnapshot: map[string]any{"pricingVersion": "effective-pricing-v2"},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
firstClaims, err := db.ClaimBillingSettlements(ctx, "worker-one", BillingSettlementBatchSize, BillingSettlementLockTimeout)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first := settlementForTask(t, firstClaims, created.ID)
|
||||
if _, err := db.pool.Exec(ctx, `UPDATE settlement_outbox SET locked_at=now()-interval '3 minutes' WHERE id=$1::uuid`, first.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
secondClaims, err := db.ClaimBillingSettlements(ctx, "worker-two", BillingSettlementBatchSize, BillingSettlementLockTimeout)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second := settlementForTask(t, secondClaims, created.ID)
|
||||
if err := db.ProcessBillingSettlement(ctx, first); !errors.Is(err, pgx.ErrNoRows) {
|
||||
t.Fatalf("stale settlement lock error=%v", err)
|
||||
}
|
||||
if err := db.ProcessBillingSettlement(ctx, second); err != nil {
|
||||
t.Fatalf("process takeover: %v", err)
|
||||
}
|
||||
|
||||
var walletExact bool
|
||||
if err := db.pool.QueryRow(ctx, `
|
||||
SELECT balance = 8.999999999::numeric
|
||||
AND frozen_balance = 0::numeric
|
||||
AND total_spent = 1.000000001::numeric
|
||||
FROM gateway_wallet_accounts
|
||||
WHERE gateway_user_id=$1::uuid AND currency='resource'`, gatewayUserID).Scan(&walletExact); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !walletExact {
|
||||
t.Fatal("wallet amounts did not preserve nine-decimal settlement")
|
||||
}
|
||||
var billingTransactions int
|
||||
if err := db.pool.QueryRow(ctx, `SELECT count(*) FROM gateway_wallet_transactions WHERE reference_id=$1 AND transaction_type='task_billing'`, created.ID).Scan(&billingTransactions); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if billingTransactions != 1 {
|
||||
t.Fatalf("task billing transactions=%d", billingTransactions)
|
||||
}
|
||||
if _, err := db.pool.Exec(ctx, `
|
||||
DELETE FROM gateway_wallet_accounts
|
||||
WHERE gateway_user_id=$1::uuid AND currency='resource'`, gatewayUserID); err == nil {
|
||||
t.Fatal("wallet account with audit transactions must not be deletable")
|
||||
}
|
||||
if err := db.pool.QueryRow(ctx, `
|
||||
SELECT count(*) FROM gateway_wallet_transactions
|
||||
WHERE gateway_user_id=$1::uuid AND reference_id=$2`, gatewayUserID, created.ID).Scan(&billingTransactions); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if billingTransactions == 0 {
|
||||
t.Fatal("wallet audit transactions were lost after rejected account deletion")
|
||||
}
|
||||
settled, err := db.GetTask(ctx, created.ID)
|
||||
if err != nil || settled.BillingStatus != "settled" {
|
||||
t.Fatalf("settled task=%+v err=%v", settled, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseTaskBillingReservationsPreservesNineDecimalPlaces(t *testing.T) {
|
||||
db := billingV2IntegrationStore(t)
|
||||
ctx := context.Background()
|
||||
tenantID, gatewayUserID := seedWalletReservationUser(t, ctx, db)
|
||||
user := &auth.User{ID: "billing-release-" + uuid.NewString(), GatewayUserID: gatewayUserID, GatewayTenantID: tenantID}
|
||||
if _, err := db.SetUserWalletBalance(ctx, WalletBalanceAdjustmentInput{
|
||||
GatewayUserID: gatewayUserID, Currency: "resource", Balance: 1, Reason: "billing v2 release test",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
created, err := db.CreateTask(ctx, CreateTaskInput{
|
||||
Kind: "images.generations", Model: "billing-v2-model", RunMode: "production",
|
||||
Request: map[string]any{"model": "billing-v2-model"},
|
||||
}, user)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
cleanupCtx := context.Background()
|
||||
_, _ = db.pool.Exec(cleanupCtx, `DELETE FROM gateway_wallet_transactions WHERE gateway_user_id=$1::uuid`, gatewayUserID)
|
||||
_, _ = db.pool.Exec(cleanupCtx, `DELETE FROM gateway_tasks WHERE id=$1::uuid`, created.ID)
|
||||
_, _ = db.pool.Exec(cleanupCtx, `DELETE FROM gateway_wallet_accounts WHERE gateway_user_id=$1::uuid`, gatewayUserID)
|
||||
})
|
||||
amount := "0.000000001"
|
||||
reservations, err := db.ReserveTaskBilling(ctx, created, user, nil, map[string]any{
|
||||
"pricingVersion": "effective-pricing-v2", "reservationAmount": amount, "currency": "resource",
|
||||
})
|
||||
if err != nil || len(reservations) != 1 {
|
||||
t.Fatalf("reserve=%+v err=%v", reservations, err)
|
||||
}
|
||||
if _, err := db.SetUserWalletBalance(ctx, WalletBalanceAdjustmentInput{
|
||||
GatewayUserID: gatewayUserID, Currency: "resource", BalanceText: "0", Reason: "must not cross frozen balance",
|
||||
}); !errors.Is(err, ErrBalanceBelowFrozen) {
|
||||
t.Fatalf("balance below frozen error=%v", err)
|
||||
}
|
||||
if err := db.ReleaseTaskBillingReservations(ctx, reservations, "integration_test"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := db.SetUserWalletBalance(ctx, WalletBalanceAdjustmentInput{
|
||||
GatewayUserID: gatewayUserID, Currency: "resource", BalanceText: "0.123456789", Reason: "exact adjustment",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := db.RechargeUserWalletBalance(ctx, WalletRechargeInput{
|
||||
GatewayUserID: gatewayUserID, Currency: "resource", AmountText: "0.000000001", Reason: "exact recharge",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var exact bool
|
||||
if err := db.pool.QueryRow(ctx, `
|
||||
SELECT account.balance = 0.123456790::numeric
|
||||
AND account.frozen_balance = 0::numeric
|
||||
AND task.reservation_amount = 0::numeric
|
||||
AND task.billing_status = 'not_started'
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM gateway_wallet_transactions transaction
|
||||
WHERE transaction.reference_id = task.id::text
|
||||
AND transaction.transaction_type = 'release'
|
||||
AND transaction.amount = 0.000000001::numeric
|
||||
)
|
||||
FROM gateway_wallet_accounts account
|
||||
JOIN gateway_tasks task ON task.gateway_user_id = account.gateway_user_id
|
||||
WHERE task.id=$1::uuid AND account.currency='resource'`, created.ID).Scan(&exact); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !exact {
|
||||
t.Fatal("nine-decimal reservation was not released exactly")
|
||||
}
|
||||
}
|
||||
|
||||
func billingV2IntegrationStore(t *testing.T) *Store {
|
||||
t.Helper()
|
||||
databaseURL := strings.TrimSpace(os.Getenv("AI_GATEWAY_TEST_DATABASE_URL"))
|
||||
if databaseURL == "" {
|
||||
t.Skip("set AI_GATEWAY_TEST_DATABASE_URL to run billing v2 PostgreSQL integration tests")
|
||||
}
|
||||
db, err := Connect(context.Background(), databaseURL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var databaseName string
|
||||
if err := db.pool.QueryRow(context.Background(), `SELECT current_database()`).Scan(&databaseName); err != nil {
|
||||
db.Close()
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(strings.ToLower(databaseName), "test") {
|
||||
db.Close()
|
||||
t.Fatalf("refusing to use non-test database %q", databaseName)
|
||||
}
|
||||
t.Cleanup(db.Close)
|
||||
return db
|
||||
}
|
||||
|
||||
func settlementForTask(t *testing.T, items []BillingSettlement, taskID string) BillingSettlement {
|
||||
t.Helper()
|
||||
for _, item := range items {
|
||||
if item.TaskID == taskID {
|
||||
return item
|
||||
}
|
||||
}
|
||||
t.Fatalf("settlement for task %s not claimed", taskID)
|
||||
return BillingSettlement{}
|
||||
}
|
||||
@ -47,23 +47,16 @@ func normalizeAPIKeyScopes(scopes []string) []string {
|
||||
}
|
||||
|
||||
var (
|
||||
ErrInvalidCredentials = errors.New("invalid account or password")
|
||||
ErrInvalidInvitation = errors.New("invalid or expired invitation code")
|
||||
ErrInvalidAPIKeyScopes = errors.New("api key scopes must not be empty")
|
||||
ErrAccessRuleResourceDenied = errors.New("access rule resource is not available")
|
||||
ErrInsufficientWalletBalance = errors.New("insufficient wallet balance")
|
||||
ErrLocalUserRequired = errors.New("local gateway user is required")
|
||||
ErrWalletBalanceUnchanged = errors.New("wallet balance unchanged")
|
||||
ErrBalanceBelowFrozen = errors.New("wallet balance cannot be below frozen balance")
|
||||
ErrInvalidWalletAmount = errors.New("wallet amount must be a decimal with at most nine fractional digits")
|
||||
ErrIdempotencyKeyReused = errors.New("idempotency key was reused for a different request")
|
||||
ErrTaskExecutionLeaseUnavailable = errors.New("task execution lease is unavailable")
|
||||
ErrTaskExecutionLeaseLost = errors.New("task execution lease was lost")
|
||||
ErrTaskExecutionFinished = errors.New("task execution already finished")
|
||||
ErrTaskExecutionManualReview = errors.New("task execution requires manual review")
|
||||
ErrProtectedDefault = errors.New("protected default resource cannot be deleted")
|
||||
ErrUserAlreadyExists = errors.New("user already exists")
|
||||
ErrWeakPassword = errors.New("password must be at least 8 characters")
|
||||
ErrInvalidCredentials = errors.New("invalid account or password")
|
||||
ErrInvalidInvitation = errors.New("invalid or expired invitation code")
|
||||
ErrInvalidAPIKeyScopes = errors.New("api key scopes must not be empty")
|
||||
ErrAccessRuleResourceDenied = errors.New("access rule resource is not available")
|
||||
ErrInsufficientWalletBalance = errors.New("insufficient wallet balance")
|
||||
ErrLocalUserRequired = errors.New("local gateway user is required")
|
||||
ErrWalletBalanceUnchanged = errors.New("wallet balance unchanged")
|
||||
ErrProtectedDefault = errors.New("protected default resource cannot be deleted")
|
||||
ErrUserAlreadyExists = errors.New("user already exists")
|
||||
ErrWeakPassword = errors.New("password must be at least 8 characters")
|
||||
)
|
||||
|
||||
func Connect(ctx context.Context, databaseURL string) (*Store, error) {
|
||||
@ -302,7 +295,6 @@ type PricingRule struct {
|
||||
ResourceType string `json:"resourceType"`
|
||||
Unit string `json:"unit"`
|
||||
BasePrice float64 `json:"basePrice"`
|
||||
IsFree bool `json:"isFree"`
|
||||
Currency string `json:"currency"`
|
||||
BaseWeight map[string]any `json:"baseWeight,omitempty"`
|
||||
DynamicWeight map[string]any `json:"dynamicWeight,omitempty"`
|
||||
@ -312,8 +304,6 @@ type PricingRule struct {
|
||||
Priority int `json:"priority"`
|
||||
Status string `json:"status"`
|
||||
Metadata map[string]any `json:"metadata,omitempty"`
|
||||
EffectiveFrom string `json:"effectiveFrom,omitempty"`
|
||||
EffectiveTo string `json:"effectiveTo,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
@ -421,81 +411,64 @@ type RateLimitWindow struct {
|
||||
}
|
||||
|
||||
type CreateTaskInput struct {
|
||||
Kind string `json:"kind"`
|
||||
Model string `json:"model"`
|
||||
RunMode string `json:"runMode"`
|
||||
Async bool `json:"async"`
|
||||
Request map[string]any `json:"request"`
|
||||
ConversationID string `json:"conversationId"`
|
||||
NewMessageCount int `json:"newMessageCount"`
|
||||
MessageRefs []TaskMessageRefInput `json:"messageRefs"`
|
||||
IdempotencyKeyHash string `json:"-"`
|
||||
IdempotencyRequestHash string `json:"-"`
|
||||
}
|
||||
|
||||
type CreateTaskResult struct {
|
||||
Task GatewayTask
|
||||
Replayed bool
|
||||
Kind string `json:"kind"`
|
||||
Model string `json:"model"`
|
||||
RunMode string `json:"runMode"`
|
||||
Async bool `json:"async"`
|
||||
Request map[string]any `json:"request"`
|
||||
ConversationID string `json:"conversationId"`
|
||||
NewMessageCount int `json:"newMessageCount"`
|
||||
MessageRefs []TaskMessageRefInput `json:"messageRefs"`
|
||||
}
|
||||
|
||||
type GatewayTask struct {
|
||||
ID string `json:"id"`
|
||||
Kind string `json:"kind"`
|
||||
RunMode string `json:"runMode"`
|
||||
UserID string `json:"userId"`
|
||||
GatewayUserID string `json:"gatewayUserId,omitempty"`
|
||||
UserSource string `json:"userSource,omitempty"`
|
||||
GatewayTenantID string `json:"gatewayTenantId,omitempty"`
|
||||
TenantID string `json:"tenantId,omitempty"`
|
||||
TenantKey string `json:"tenantKey,omitempty"`
|
||||
APIKeyID string `json:"apiKeyId,omitempty"`
|
||||
APIKeyName string `json:"apiKeyName,omitempty"`
|
||||
APIKeyPrefix string `json:"apiKeyPrefix,omitempty"`
|
||||
UserGroupID string `json:"userGroupId,omitempty"`
|
||||
UserGroupKey string `json:"userGroupKey,omitempty"`
|
||||
Model string `json:"model"`
|
||||
ModelType string `json:"modelType,omitempty"`
|
||||
RequestedModel string `json:"requestedModel,omitempty"`
|
||||
ResolvedModel string `json:"resolvedModel,omitempty"`
|
||||
RequestID string `json:"requestId,omitempty"`
|
||||
ConversationID string `json:"conversationId,omitempty"`
|
||||
NewMessageCount int `json:"newMessageCount,omitempty"`
|
||||
Request map[string]any `json:"request,omitempty"`
|
||||
AsyncMode bool `json:"asyncMode"`
|
||||
RiverJobID int64 `json:"riverJobId,omitempty"`
|
||||
Status string `json:"status"`
|
||||
Cancellable *bool `json:"cancellable,omitempty"`
|
||||
Submitted *bool `json:"submitted,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
AttemptCount int `json:"attemptCount"`
|
||||
RemoteTaskID string `json:"remoteTaskId,omitempty"`
|
||||
RemoteTaskPayload map[string]any `json:"remoteTaskPayload,omitempty"`
|
||||
Result map[string]any `json:"result,omitempty"`
|
||||
Billings []any `json:"billings,omitempty"`
|
||||
Usage map[string]any `json:"usage"`
|
||||
Metrics map[string]any `json:"metrics"`
|
||||
BillingSummary map[string]any `json:"billingSummary"`
|
||||
FinalChargeAmount float64 `json:"finalChargeAmount"`
|
||||
BillingVersion string `json:"billingVersion"`
|
||||
BillingStatus string `json:"billingStatus"`
|
||||
BillingCurrency string `json:"billingCurrency"`
|
||||
PricingSnapshot map[string]any `json:"pricingSnapshot,omitempty"`
|
||||
RequestFingerprint string `json:"requestFingerprint,omitempty"`
|
||||
ReservationAmount float64 `json:"reservationAmount"`
|
||||
ExecutionToken string `json:"-"`
|
||||
ExecutionLeaseUntil string `json:"executionLeaseExpiresAt,omitempty"`
|
||||
BillingUpdatedAt string `json:"billingUpdatedAt,omitempty"`
|
||||
BillingSettledAt string `json:"billingSettledAt,omitempty"`
|
||||
ResponseStartedAt string `json:"responseStartedAt,omitempty"`
|
||||
ResponseFinishedAt string `json:"responseFinishedAt,omitempty"`
|
||||
ResponseDurationMS int64 `json:"responseDurationMs"`
|
||||
FinishedAt string `json:"finishedAt,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
ErrorCode string `json:"errorCode,omitempty"`
|
||||
ErrorMessage string `json:"errorMessage,omitempty"`
|
||||
Attempts []TaskAttempt `json:"attempts,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
ID string `json:"id"`
|
||||
Kind string `json:"kind"`
|
||||
RunMode string `json:"runMode"`
|
||||
UserID string `json:"userId"`
|
||||
GatewayUserID string `json:"gatewayUserId,omitempty"`
|
||||
UserSource string `json:"userSource,omitempty"`
|
||||
GatewayTenantID string `json:"gatewayTenantId,omitempty"`
|
||||
TenantID string `json:"tenantId,omitempty"`
|
||||
TenantKey string `json:"tenantKey,omitempty"`
|
||||
APIKeyID string `json:"apiKeyId,omitempty"`
|
||||
APIKeyName string `json:"apiKeyName,omitempty"`
|
||||
APIKeyPrefix string `json:"apiKeyPrefix,omitempty"`
|
||||
UserGroupID string `json:"userGroupId,omitempty"`
|
||||
UserGroupKey string `json:"userGroupKey,omitempty"`
|
||||
Model string `json:"model"`
|
||||
ModelType string `json:"modelType,omitempty"`
|
||||
RequestedModel string `json:"requestedModel,omitempty"`
|
||||
ResolvedModel string `json:"resolvedModel,omitempty"`
|
||||
RequestID string `json:"requestId,omitempty"`
|
||||
ConversationID string `json:"conversationId,omitempty"`
|
||||
NewMessageCount int `json:"newMessageCount,omitempty"`
|
||||
Request map[string]any `json:"request,omitempty"`
|
||||
AsyncMode bool `json:"asyncMode"`
|
||||
RiverJobID int64 `json:"riverJobId,omitempty"`
|
||||
Status string `json:"status"`
|
||||
Cancellable *bool `json:"cancellable,omitempty"`
|
||||
Submitted *bool `json:"submitted,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
AttemptCount int `json:"attemptCount"`
|
||||
RemoteTaskID string `json:"remoteTaskId,omitempty"`
|
||||
RemoteTaskPayload map[string]any `json:"remoteTaskPayload,omitempty"`
|
||||
Result map[string]any `json:"result,omitempty"`
|
||||
Billings []any `json:"billings,omitempty"`
|
||||
Usage map[string]any `json:"usage"`
|
||||
Metrics map[string]any `json:"metrics"`
|
||||
BillingSummary map[string]any `json:"billingSummary"`
|
||||
FinalChargeAmount float64 `json:"finalChargeAmount"`
|
||||
ResponseStartedAt string `json:"responseStartedAt,omitempty"`
|
||||
ResponseFinishedAt string `json:"responseFinishedAt,omitempty"`
|
||||
ResponseDurationMS int64 `json:"responseDurationMs"`
|
||||
FinishedAt string `json:"finishedAt,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
ErrorCode string `json:"errorCode,omitempty"`
|
||||
ErrorMessage string `json:"errorMessage,omitempty"`
|
||||
Attempts []TaskAttempt `json:"attempts,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
const gatewayTaskColumns = `
|
||||
@ -509,11 +482,7 @@ request, COALESCE(async_mode, false), COALESCE(river_job_id, 0), status, COALESC
|
||||
COALESCE(remote_task_id, ''), COALESCE(remote_task_payload, '{}'::jsonb),
|
||||
COALESCE(result, '{}'::jsonb), COALESCE(billings, '[]'::jsonb),
|
||||
COALESCE(usage, '{}'::jsonb), COALESCE(metrics, '{}'::jsonb), COALESCE(billing_summary, '{}'::jsonb),
|
||||
COALESCE(final_charge_amount, 0)::float8, billing_version, billing_status, billing_currency,
|
||||
COALESCE(pricing_snapshot, '{}'::jsonb), COALESCE(request_fingerprint, ''),
|
||||
COALESCE(reservation_amount, 0)::float8, COALESCE(execution_token::text, ''),
|
||||
COALESCE(execution_lease_expires_at::text, ''), COALESCE(billing_updated_at::text, ''),
|
||||
COALESCE(billing_settled_at::text, ''), COALESCE(response_started_at::text, ''),
|
||||
COALESCE(final_charge_amount, 0)::float8, COALESCE(response_started_at::text, ''),
|
||||
COALESCE(response_finished_at::text, ''), COALESCE(response_duration_ms, 0), COALESCE(error, ''),
|
||||
COALESCE(error_code, ''), COALESCE(error_message, ''),
|
||||
created_at, updated_at, COALESCE(finished_at::text, '')`
|
||||
@ -533,39 +502,35 @@ type TaskEvent struct {
|
||||
}
|
||||
|
||||
type TaskAttempt struct {
|
||||
ID string `json:"id"`
|
||||
TaskID string `json:"taskId"`
|
||||
AttemptNo int `json:"attemptNo"`
|
||||
PlatformID string `json:"platformId,omitempty"`
|
||||
PlatformName string `json:"platformName,omitempty"`
|
||||
Provider string `json:"provider,omitempty"`
|
||||
PlatformModelID string `json:"platformModelId,omitempty"`
|
||||
ModelName string `json:"modelName,omitempty"`
|
||||
ProviderModelName string `json:"providerModelName,omitempty"`
|
||||
ModelAlias string `json:"modelAlias,omitempty"`
|
||||
ModelType string `json:"modelType,omitempty"`
|
||||
ClientID string `json:"clientId,omitempty"`
|
||||
QueueKey string `json:"queueKey"`
|
||||
Status string `json:"status"`
|
||||
Retryable bool `json:"retryable"`
|
||||
Simulated bool `json:"simulated"`
|
||||
RequestID string `json:"requestId,omitempty"`
|
||||
StatusCode int `json:"statusCode,omitempty"`
|
||||
Usage map[string]any `json:"usage,omitempty"`
|
||||
Metrics map[string]any `json:"metrics,omitempty"`
|
||||
RequestSnapshot map[string]any `json:"requestSnapshot,omitempty"`
|
||||
ResponseSnapshot map[string]any `json:"responseSnapshot,omitempty"`
|
||||
ResponseStartedAt string `json:"responseStartedAt,omitempty"`
|
||||
ResponseFinishedAt string `json:"responseFinishedAt,omitempty"`
|
||||
ResponseDurationMS int64 `json:"responseDurationMs"`
|
||||
PricingSnapshot map[string]any `json:"pricingSnapshot,omitempty"`
|
||||
RequestFingerprint string `json:"requestFingerprint,omitempty"`
|
||||
UpstreamSubmissionStatus string `json:"upstreamSubmissionStatus"`
|
||||
UpstreamSubmissionUpdatedAt string `json:"upstreamSubmissionUpdatedAt,omitempty"`
|
||||
ErrorCode string `json:"errorCode,omitempty"`
|
||||
ErrorMessage string `json:"errorMessage,omitempty"`
|
||||
StartedAt time.Time `json:"startedAt"`
|
||||
FinishedAt string `json:"finishedAt,omitempty"`
|
||||
ID string `json:"id"`
|
||||
TaskID string `json:"taskId"`
|
||||
AttemptNo int `json:"attemptNo"`
|
||||
PlatformID string `json:"platformId,omitempty"`
|
||||
PlatformName string `json:"platformName,omitempty"`
|
||||
Provider string `json:"provider,omitempty"`
|
||||
PlatformModelID string `json:"platformModelId,omitempty"`
|
||||
ModelName string `json:"modelName,omitempty"`
|
||||
ProviderModelName string `json:"providerModelName,omitempty"`
|
||||
ModelAlias string `json:"modelAlias,omitempty"`
|
||||
ModelType string `json:"modelType,omitempty"`
|
||||
ClientID string `json:"clientId,omitempty"`
|
||||
QueueKey string `json:"queueKey"`
|
||||
Status string `json:"status"`
|
||||
Retryable bool `json:"retryable"`
|
||||
Simulated bool `json:"simulated"`
|
||||
RequestID string `json:"requestId,omitempty"`
|
||||
StatusCode int `json:"statusCode,omitempty"`
|
||||
Usage map[string]any `json:"usage,omitempty"`
|
||||
Metrics map[string]any `json:"metrics,omitempty"`
|
||||
RequestSnapshot map[string]any `json:"requestSnapshot,omitempty"`
|
||||
ResponseSnapshot map[string]any `json:"responseSnapshot,omitempty"`
|
||||
ResponseStartedAt string `json:"responseStartedAt,omitempty"`
|
||||
ResponseFinishedAt string `json:"responseFinishedAt,omitempty"`
|
||||
ResponseDurationMS int64 `json:"responseDurationMs"`
|
||||
ErrorCode string `json:"errorCode,omitempty"`
|
||||
ErrorMessage string `json:"errorMessage,omitempty"`
|
||||
StartedAt time.Time `json:"startedAt"`
|
||||
FinishedAt string `json:"finishedAt,omitempty"`
|
||||
}
|
||||
|
||||
type TaskParamPreprocessingLog struct {
|
||||
@ -1748,9 +1713,6 @@ ON CONFLICT (gateway_user_id, currency) DO NOTHING`,
|
||||
); err != nil {
|
||||
return GatewayUser{}, err
|
||||
}
|
||||
if err := ensureWalletAccountAuditGuard(ctx, tx, user.ID, "resource"); err != nil {
|
||||
return GatewayUser{}, err
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return GatewayUser{}, err
|
||||
}
|
||||
@ -1861,11 +1823,6 @@ ORDER BY window_start DESC, scope_type ASC, scope_key ASC, metric ASC`)
|
||||
}
|
||||
|
||||
func (s *Store) CreateTask(ctx context.Context, input CreateTaskInput, user *auth.User) (GatewayTask, error) {
|
||||
result, err := s.CreateTaskIdempotent(ctx, input, user)
|
||||
return result.Task, err
|
||||
}
|
||||
|
||||
func (s *Store) CreateTaskIdempotent(ctx context.Context, input CreateTaskInput, user *auth.User) (CreateTaskResult, error) {
|
||||
requestBody, _ := json.Marshal(input.Request)
|
||||
runMode := normalizeRunMode(input.RunMode, input.Request)
|
||||
status := "queued"
|
||||
@ -1874,7 +1831,7 @@ func (s *Store) CreateTaskIdempotent(ctx context.Context, input CreateTaskInput,
|
||||
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return CreateTaskResult{}, err
|
||||
return GatewayTask{}, err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
@ -1882,62 +1839,33 @@ func (s *Store) CreateTaskIdempotent(ctx context.Context, input CreateTaskInput,
|
||||
INSERT INTO gateway_tasks (
|
||||
kind, run_mode, user_id, gateway_user_id, user_source, gateway_tenant_id, tenant_id, tenant_key,
|
||||
api_key_id, api_key_name, api_key_prefix, user_group_id, user_group_key,
|
||||
model, requested_model, request, async_mode, status, result, billings, conversation_id, new_message_count,
|
||||
idempotency_key_hash, idempotency_request_hash, finished_at
|
||||
model, requested_model, request, async_mode, status, result, billings, conversation_id, new_message_count, finished_at
|
||||
)
|
||||
VALUES ($1, $2, $3, NULLIF($4, '')::uuid, COALESCE(NULLIF($5, ''), 'gateway'), NULLIF($6, '')::uuid, NULLIF($7, ''), NULLIF($8, ''), NULLIF($9, ''), NULLIF($10, ''), NULLIF($11, ''), NULLIF($12, '')::uuid, NULLIF($13, ''), $14, $14, $15, $16, $17, $18::jsonb, $19::jsonb, NULLIF($20, '')::uuid, $21, NULLIF($22, ''), NULLIF($23, ''), NULL)
|
||||
ON CONFLICT (user_id, idempotency_key_hash) WHERE idempotency_key_hash IS NOT NULL DO NOTHING
|
||||
VALUES ($1, $2, $3, NULLIF($4, '')::uuid, COALESCE(NULLIF($5, ''), 'gateway'), NULLIF($6, '')::uuid, NULLIF($7, ''), NULLIF($8, ''), NULLIF($9, ''), NULLIF($10, ''), NULLIF($11, ''), NULLIF($12, '')::uuid, NULLIF($13, ''), $14, $14, $15, $16, $17, $18::jsonb, $19::jsonb, NULLIF($20, '')::uuid, $21, CASE WHEN $22 THEN now() ELSE NULL END)
|
||||
RETURNING `+gatewayTaskColumns,
|
||||
input.Kind, runMode, user.ID, user.GatewayUserID, user.Source, user.GatewayTenantID, user.TenantID, user.TenantKey, user.APIKeyID, user.APIKeyName, user.APIKeyPrefix, user.UserGroupID, user.UserGroupKey, input.Model, requestBody, input.Async, status, resultBody, billingsBody, input.ConversationID, input.NewMessageCount, strings.TrimSpace(input.IdempotencyKeyHash), strings.TrimSpace(input.IdempotencyRequestHash),
|
||||
input.Kind, runMode, user.ID, user.GatewayUserID, user.Source, user.GatewayTenantID, user.TenantID, user.TenantKey, user.APIKeyID, user.APIKeyName, user.APIKeyPrefix, user.UserGroupID, user.UserGroupKey, input.Model, requestBody, input.Async, status, resultBody, billingsBody, input.ConversationID, input.NewMessageCount, false,
|
||||
))
|
||||
replayed := false
|
||||
if errors.Is(err, pgx.ErrNoRows) && strings.TrimSpace(input.IdempotencyKeyHash) != "" {
|
||||
var existingRequestHash string
|
||||
if err := tx.QueryRow(ctx, `
|
||||
SELECT COALESCE(idempotency_request_hash, '')
|
||||
FROM gateway_tasks
|
||||
WHERE user_id = $1 AND idempotency_key_hash = $2
|
||||
FOR UPDATE`, user.ID, strings.TrimSpace(input.IdempotencyKeyHash)).Scan(&existingRequestHash); err != nil {
|
||||
return CreateTaskResult{}, err
|
||||
}
|
||||
if existingRequestHash != strings.TrimSpace(input.IdempotencyRequestHash) {
|
||||
return CreateTaskResult{}, ErrIdempotencyKeyReused
|
||||
}
|
||||
task, err = scanGatewayTask(tx.QueryRow(ctx, `
|
||||
SELECT `+gatewayTaskColumns+`
|
||||
FROM gateway_tasks
|
||||
WHERE user_id = $1 AND idempotency_key_hash = $2`, user.ID, strings.TrimSpace(input.IdempotencyKeyHash)))
|
||||
replayed = true
|
||||
}
|
||||
if err != nil {
|
||||
return CreateTaskResult{}, err
|
||||
return GatewayTask{}, err
|
||||
}
|
||||
if !replayed {
|
||||
if err := insertTaskMessageRefs(ctx, tx, task.ID, input.MessageRefs); err != nil {
|
||||
return CreateTaskResult{}, err
|
||||
}
|
||||
events := taskEventsForCreate(task.ID, runMode, status, nil)
|
||||
for _, event := range events {
|
||||
payload, _ := json.Marshal(event.Payload)
|
||||
if _, err := tx.Exec(ctx, `
|
||||
if err := insertTaskMessageRefs(ctx, tx, task.ID, input.MessageRefs); err != nil {
|
||||
return GatewayTask{}, err
|
||||
}
|
||||
events := taskEventsForCreate(task.ID, runMode, status, nil)
|
||||
for _, event := range events {
|
||||
payload, _ := json.Marshal(event.Payload)
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO gateway_task_events (task_id, seq, event_type, status, phase, progress, message, payload, simulated)
|
||||
VALUES ($1::uuid, $2, $3::text, NULLIF($4::text, ''), NULLIF($5::text, ''), $6, NULLIF($7::text, ''), $8::jsonb, $9)`,
|
||||
task.ID, event.Seq, event.EventType, event.Status, event.Phase, event.Progress, event.Message, string(payload), event.Simulated,
|
||||
); err != nil {
|
||||
return CreateTaskResult{}, err
|
||||
}
|
||||
task.ID, event.Seq, event.EventType, event.Status, event.Phase, event.Progress, event.Message, string(payload), event.Simulated,
|
||||
); err != nil {
|
||||
return GatewayTask{}, err
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return CreateTaskResult{}, err
|
||||
return GatewayTask{}, err
|
||||
}
|
||||
if replayed {
|
||||
task, err = s.GetTask(ctx, task.ID)
|
||||
if err != nil {
|
||||
return CreateTaskResult{}, err
|
||||
}
|
||||
}
|
||||
return CreateTaskResult{Task: task, Replayed: replayed}, nil
|
||||
return task, nil
|
||||
}
|
||||
|
||||
func (s *Store) GetTask(ctx context.Context, taskID string) (GatewayTask, error) {
|
||||
@ -1969,7 +1897,6 @@ func scanGatewayTask(scanner taskScanner) (GatewayTask, error) {
|
||||
var usageBytes []byte
|
||||
var metricsBytes []byte
|
||||
var billingSummaryBytes []byte
|
||||
var pricingSnapshotBytes []byte
|
||||
var remoteTaskPayloadBytes []byte
|
||||
if err := scanner.Scan(
|
||||
&task.ID,
|
||||
@ -2006,16 +1933,6 @@ func scanGatewayTask(scanner taskScanner) (GatewayTask, error) {
|
||||
&metricsBytes,
|
||||
&billingSummaryBytes,
|
||||
&task.FinalChargeAmount,
|
||||
&task.BillingVersion,
|
||||
&task.BillingStatus,
|
||||
&task.BillingCurrency,
|
||||
&pricingSnapshotBytes,
|
||||
&task.RequestFingerprint,
|
||||
&task.ReservationAmount,
|
||||
&task.ExecutionToken,
|
||||
&task.ExecutionLeaseUntil,
|
||||
&task.BillingUpdatedAt,
|
||||
&task.BillingSettledAt,
|
||||
&task.ResponseStartedAt,
|
||||
&task.ResponseFinishedAt,
|
||||
&task.ResponseDurationMS,
|
||||
@ -2035,7 +1952,6 @@ func scanGatewayTask(scanner taskScanner) (GatewayTask, error) {
|
||||
task.Usage = decodeObject(usageBytes)
|
||||
task.Metrics = decodeObject(metricsBytes)
|
||||
task.BillingSummary = decodeObject(billingSummaryBytes)
|
||||
task.PricingSnapshot = decodeObject(pricingSnapshotBytes)
|
||||
return task, nil
|
||||
}
|
||||
|
||||
|
||||
@ -3,10 +3,8 @@ package store
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
@ -17,10 +15,9 @@ status, metadata, created_at, updated_at`
|
||||
|
||||
const pricingRuleColumns = `
|
||||
id::text, COALESCE(rule_set_id::text, ''), rule_key, display_name, scope_type,
|
||||
COALESCE(scope_id::text, ''), resource_type, unit, base_price::float8, is_free, currency,
|
||||
COALESCE(scope_id::text, ''), resource_type, unit, base_price::float8, currency,
|
||||
base_weight, dynamic_weight, calculator_type, dimension_schema, formula_config,
|
||||
priority, status, metadata, COALESCE(effective_from::text, ''), COALESCE(effective_to::text, ''),
|
||||
created_at, updated_at`
|
||||
priority, status, metadata, created_at, updated_at`
|
||||
|
||||
type PricingRuleInput struct {
|
||||
RuleKey string `json:"ruleKey"`
|
||||
@ -28,7 +25,6 @@ type PricingRuleInput struct {
|
||||
ResourceType string `json:"resourceType"`
|
||||
Unit string `json:"unit"`
|
||||
BasePrice float64 `json:"basePrice"`
|
||||
IsFree bool `json:"isFree"`
|
||||
Currency string `json:"currency"`
|
||||
BaseWeight map[string]any `json:"baseWeight"`
|
||||
DynamicWeight map[string]any `json:"dynamicWeight"`
|
||||
@ -38,17 +34,6 @@ type PricingRuleInput struct {
|
||||
Priority int `json:"priority"`
|
||||
Status string `json:"status"`
|
||||
Metadata map[string]any `json:"metadata"`
|
||||
EffectiveFrom string `json:"effectiveFrom"`
|
||||
EffectiveTo string `json:"effectiveTo"`
|
||||
}
|
||||
|
||||
type EffectivePricingConfig struct {
|
||||
RuleSetID string
|
||||
RuleSetKey string
|
||||
Currency string
|
||||
Config map[string]any
|
||||
FreeResource map[string]bool
|
||||
Snapshot map[string]any
|
||||
}
|
||||
|
||||
type PricingRuleSetInput struct {
|
||||
@ -262,221 +247,6 @@ ORDER BY priority ASC, resource_type ASC`, id)
|
||||
return config, nil
|
||||
}
|
||||
|
||||
func (s *Store) PricingRuleSetBillingConfigV2(ctx context.Context, id string) (EffectivePricingConfig, error) {
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" {
|
||||
return EffectivePricingConfig{}, fmt.Errorf("pricing rule set id is required")
|
||||
}
|
||||
var ruleSetKey string
|
||||
var currency string
|
||||
var status string
|
||||
if err := s.pool.QueryRow(ctx, `
|
||||
SELECT rule_set_key, currency, status
|
||||
FROM model_pricing_rule_sets
|
||||
WHERE id = $1::uuid`, id).Scan(&ruleSetKey, ¤cy, &status); err != nil {
|
||||
return EffectivePricingConfig{}, err
|
||||
}
|
||||
if status != "active" {
|
||||
return EffectivePricingConfig{}, fmt.Errorf("pricing rule set %s is not active", id)
|
||||
}
|
||||
if currency != "resource" {
|
||||
return EffectivePricingConfig{}, fmt.Errorf("pricing rule set %s uses unsupported currency %s", id, currency)
|
||||
}
|
||||
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT rule_key, resource_type, unit, base_price::text, currency, base_weight,
|
||||
dynamic_weight, calculator_type, dimension_schema, formula_config,
|
||||
priority, is_free, COALESCE(effective_from::text, ''), COALESCE(effective_to::text, '')
|
||||
FROM model_pricing_rules
|
||||
WHERE rule_set_id = $1::uuid
|
||||
AND status = 'active'
|
||||
AND (effective_from IS NULL OR effective_from <= now())
|
||||
AND (effective_to IS NULL OR effective_to > now())
|
||||
ORDER BY priority ASC, resource_type ASC, rule_key ASC`, id)
|
||||
if err != nil {
|
||||
return EffectivePricingConfig{}, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
config := map[string]any{}
|
||||
freeResource := map[string]bool{}
|
||||
rules := make([]any, 0)
|
||||
seen := map[string]bool{}
|
||||
for rows.Next() {
|
||||
var ruleKey string
|
||||
var resourceType string
|
||||
var unit string
|
||||
var basePrice string
|
||||
var ruleCurrency string
|
||||
var baseWeightBytes []byte
|
||||
var dynamicWeightBytes []byte
|
||||
var calculatorType string
|
||||
var dimensionSchemaBytes []byte
|
||||
var formulaConfigBytes []byte
|
||||
var priority int
|
||||
var isFree bool
|
||||
var effectiveFrom string
|
||||
var effectiveTo string
|
||||
if err := rows.Scan(
|
||||
&ruleKey, &resourceType, &unit, &basePrice, &ruleCurrency, &baseWeightBytes,
|
||||
&dynamicWeightBytes, &calculatorType, &dimensionSchemaBytes, &formulaConfigBytes,
|
||||
&priority, &isFree, &effectiveFrom, &effectiveTo,
|
||||
); err != nil {
|
||||
return EffectivePricingConfig{}, err
|
||||
}
|
||||
if seen[resourceType] {
|
||||
continue
|
||||
}
|
||||
if ruleCurrency != currency {
|
||||
return EffectivePricingConfig{}, fmt.Errorf("pricing rule %s currency does not match its rule set", ruleKey)
|
||||
}
|
||||
switch calculatorType {
|
||||
case "token_usage", "unit_weight", "duration_weight":
|
||||
default:
|
||||
return EffectivePricingConfig{}, fmt.Errorf("pricing rule %s uses unsupported calculator %s", ruleKey, calculatorType)
|
||||
}
|
||||
if err := ValidateEffectivePricingRuleShape(resourceType, unit, calculatorType); err != nil {
|
||||
return EffectivePricingConfig{}, fmt.Errorf("pricing rule %s: %w", ruleKey, err)
|
||||
}
|
||||
if strings.HasPrefix(strings.TrimSpace(basePrice), "-") {
|
||||
return EffectivePricingConfig{}, fmt.Errorf("pricing rule %s has a negative base price", ruleKey)
|
||||
}
|
||||
dynamicWeight := decodeObject(dynamicWeightBytes)
|
||||
formulaConfig := decodeObject(formulaConfigBytes)
|
||||
addPricingRuleToConfig(config, resourceType, basePrice, dynamicWeight, formulaConfig)
|
||||
freeResource[resourceType] = isFree
|
||||
seen[resourceType] = true
|
||||
rules = append(rules, map[string]any{
|
||||
"ruleKey": ruleKey, "resourceType": resourceType, "unit": unit,
|
||||
"basePrice": basePrice, "currency": ruleCurrency, "baseWeight": decodeObject(baseWeightBytes),
|
||||
"dynamicWeight": dynamicWeight, "calculatorType": calculatorType,
|
||||
"dimensionSchema": decodeObject(dimensionSchemaBytes), "formulaConfig": formulaConfig,
|
||||
"priority": priority, "isFree": isFree,
|
||||
"effectiveFrom": effectiveFrom, "effectiveTo": effectiveTo,
|
||||
})
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return EffectivePricingConfig{}, err
|
||||
}
|
||||
if len(rules) == 0 {
|
||||
return EffectivePricingConfig{}, fmt.Errorf("pricing rule set %s has no effective rules", id)
|
||||
}
|
||||
return EffectivePricingConfig{
|
||||
RuleSetID: id, RuleSetKey: ruleSetKey, Currency: currency, Config: config,
|
||||
FreeResource: freeResource,
|
||||
Snapshot: map[string]any{
|
||||
"pricingVersion": "effective-pricing-v2",
|
||||
"ruleSetId": id,
|
||||
"ruleSetKey": ruleSetKey,
|
||||
"currency": currency,
|
||||
"resolvedAt": time.Now().UTC().Format(time.RFC3339Nano),
|
||||
"rules": rules,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func ValidateEffectivePricingRuleShape(resourceType string, unit string, calculatorType string) error {
|
||||
unit = NormalizeEffectivePricingRuleUnit(resourceType, unit)
|
||||
if strings.TrimSpace(calculatorType) == "" {
|
||||
calculatorType = DefaultEffectivePricingCalculator(resourceType)
|
||||
}
|
||||
allowed := false
|
||||
switch resourceType {
|
||||
case "text_input", "text_cached_input", "text_output", "text_total":
|
||||
allowed = unit == "1k_tokens" && calculatorType == "token_usage"
|
||||
case "image", "image_edit":
|
||||
allowed = unit == "image" && calculatorType == "unit_weight"
|
||||
case "video":
|
||||
allowed = unit == "5s" && calculatorType == "duration_weight"
|
||||
case "music":
|
||||
allowed = (unit == "song" || unit == "item") && calculatorType == "unit_weight"
|
||||
case "audio":
|
||||
allowed = unit == "character" && calculatorType == "unit_weight"
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
if !allowed {
|
||||
return fmt.Errorf("unit %q and calculator %q do not match resource %q", unit, calculatorType, resourceType)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func NormalizeEffectivePricingRuleUnit(resourceType string, unit string) string {
|
||||
unit = strings.TrimSpace(unit)
|
||||
if resourceType == "video" && unit == "video" {
|
||||
return "5s"
|
||||
}
|
||||
return unit
|
||||
}
|
||||
|
||||
func DefaultEffectivePricingCalculator(resourceType string) string {
|
||||
switch strings.TrimSpace(resourceType) {
|
||||
case "text_input", "text_cached_input", "text_output", "text_total":
|
||||
return "token_usage"
|
||||
case "video":
|
||||
return "duration_weight"
|
||||
default:
|
||||
return "unit_weight"
|
||||
}
|
||||
}
|
||||
|
||||
func addPricingRuleToConfig(config map[string]any, resourceType string, basePrice string, dynamicWeight map[string]any, formulaConfig map[string]any) {
|
||||
resourceConfig := map[string]any{"basePrice": basePrice}
|
||||
if len(dynamicWeight) > 0 {
|
||||
resourceConfig["dynamicWeight"] = dynamicWeight
|
||||
}
|
||||
if len(formulaConfig) > 0 {
|
||||
resourceConfig["formulaConfig"] = formulaConfig
|
||||
}
|
||||
switch resourceType {
|
||||
case "text_input":
|
||||
config["textInputPer1k"] = basePrice
|
||||
case "text_cached_input":
|
||||
config["textCachedInputPer1k"] = basePrice
|
||||
case "text_output":
|
||||
config["textOutputPer1k"] = basePrice
|
||||
case "text_total":
|
||||
inputPrice := any(basePrice)
|
||||
if value, ok := pricingRuleValueFromKeys(formulaConfig, "inputTokenPrice", "input_token_price", "textInputPer1k", "text_input"); ok {
|
||||
inputPrice = value
|
||||
}
|
||||
config["textInputPer1k"] = inputPrice
|
||||
if value, ok := pricingRuleValueFromKeys(formulaConfig, "cachedInputTokenPrice", "cached_input_token_price", "textCachedInputPer1k", "text_cached_input", "inputCacheHitTokenPrice", "input_cache_hit_token_price"); ok {
|
||||
config["textCachedInputPer1k"] = value
|
||||
}
|
||||
if value, ok := pricingRuleValueFromKeys(formulaConfig, "outputTokenPrice", "output_token_price", "textOutputPer1k", "text_output"); ok {
|
||||
config["textOutputPer1k"] = value
|
||||
}
|
||||
config["text_total"] = resourceConfig
|
||||
case "image":
|
||||
config["imageBase"] = basePrice
|
||||
config["image"] = resourceConfig
|
||||
case "image_edit":
|
||||
config["editBase"] = basePrice
|
||||
config["image_edit"] = resourceConfig
|
||||
case "video":
|
||||
config["videoBase"] = basePrice
|
||||
config["video"] = resourceConfig
|
||||
case "music":
|
||||
config["musicBase"] = basePrice
|
||||
config["music"] = resourceConfig
|
||||
case "audio":
|
||||
config["audioBase"] = basePrice
|
||||
config["audio"] = resourceConfig
|
||||
default:
|
||||
config[resourceType] = resourceConfig
|
||||
}
|
||||
}
|
||||
|
||||
func pricingRuleValueFromKeys(config map[string]any, keys ...string) (any, bool) {
|
||||
for _, key := range keys {
|
||||
if value, ok := config[key]; ok {
|
||||
return value, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func pricingRuleNumberFromKeys(config map[string]any, keys ...string) (float64, bool) {
|
||||
if len(config) == 0 {
|
||||
return 0, false
|
||||
@ -535,16 +305,13 @@ func insertPricingRules(ctx context.Context, tx pgx.Tx, ruleSetID string, defaul
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO model_pricing_rules (
|
||||
rule_set_id, rule_key, display_name, scope_type, scope_id, resource_type,
|
||||
unit, base_price, is_free, currency, base_weight, dynamic_weight, calculator_type,
|
||||
dimension_schema, formula_config, priority, status, metadata, effective_from, effective_to
|
||||
unit, base_price, currency, base_weight, dynamic_weight, calculator_type,
|
||||
dimension_schema, formula_config, priority, status, metadata
|
||||
)
|
||||
VALUES (
|
||||
$1::uuid, $2, $3, 'rule_set', $1::uuid, $4, $5, $6, $7, $8, $9, $10,
|
||||
$11, $12, $13, $14, $15, $16, NULLIF($17, '')::timestamptz, NULLIF($18, '')::timestamptz
|
||||
)`,
|
||||
VALUES ($1::uuid, $2, $3, 'rule_set', $1::uuid, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)`,
|
||||
ruleSetID, rule.RuleKey, rule.DisplayName, rule.ResourceType, rule.Unit,
|
||||
rule.BasePrice, rule.IsFree, rule.Currency, baseWeight, dynamicWeight, rule.CalculatorType,
|
||||
dimensionSchema, formulaConfig, rule.Priority, rule.Status, metadata, rule.EffectiveFrom, rule.EffectiveTo,
|
||||
rule.BasePrice, rule.Currency, baseWeight, dynamicWeight, rule.CalculatorType,
|
||||
dimensionSchema, formulaConfig, rule.Priority, rule.Status, metadata,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
@ -591,7 +358,6 @@ func scanPricingRule(scanner pricingScanner) (PricingRule, error) {
|
||||
&item.ResourceType,
|
||||
&item.Unit,
|
||||
&item.BasePrice,
|
||||
&item.IsFree,
|
||||
&item.Currency,
|
||||
&baseWeight,
|
||||
&dynamicWeight,
|
||||
@ -601,8 +367,6 @@ func scanPricingRule(scanner pricingScanner) (PricingRule, error) {
|
||||
&item.Priority,
|
||||
&item.Status,
|
||||
&metadata,
|
||||
&item.EffectiveFrom,
|
||||
&item.EffectiveTo,
|
||||
&item.CreatedAt,
|
||||
&item.UpdatedAt,
|
||||
); err != nil {
|
||||
@ -639,7 +403,7 @@ func normalizePricingRule(input PricingRuleInput, index int, defaultCurrency str
|
||||
input.RuleKey = strings.TrimSpace(input.RuleKey)
|
||||
input.DisplayName = strings.TrimSpace(input.DisplayName)
|
||||
input.ResourceType = strings.TrimSpace(input.ResourceType)
|
||||
input.Unit = NormalizeEffectivePricingRuleUnit(input.ResourceType, input.Unit)
|
||||
input.Unit = strings.TrimSpace(input.Unit)
|
||||
input.Currency = strings.TrimSpace(input.Currency)
|
||||
input.CalculatorType = strings.TrimSpace(input.CalculatorType)
|
||||
input.Status = strings.TrimSpace(input.Status)
|
||||
@ -656,7 +420,7 @@ func normalizePricingRule(input PricingRuleInput, index int, defaultCurrency str
|
||||
input.Currency = defaultCurrency
|
||||
}
|
||||
if input.CalculatorType == "" {
|
||||
input.CalculatorType = DefaultEffectivePricingCalculator(input.ResourceType)
|
||||
input.CalculatorType = "unit_weight"
|
||||
}
|
||||
if input.Priority == 0 {
|
||||
input.Priority = (index + 1) * 10
|
||||
@ -680,7 +444,6 @@ func pricingInputsToRules(ruleSetID string, defaultCurrency string, rules []Pric
|
||||
ResourceType: input.ResourceType,
|
||||
Unit: input.Unit,
|
||||
BasePrice: input.BasePrice,
|
||||
IsFree: input.IsFree,
|
||||
Currency: input.Currency,
|
||||
BaseWeight: emptyObjectIfNil(input.BaseWeight),
|
||||
DynamicWeight: emptyObjectIfNil(input.DynamicWeight),
|
||||
@ -690,8 +453,6 @@ func pricingInputsToRules(ruleSetID string, defaultCurrency string, rules []Pric
|
||||
Priority: input.Priority,
|
||||
Status: input.Status,
|
||||
Metadata: emptyObjectIfNil(input.Metadata),
|
||||
EffectiveFrom: input.EffectiveFrom,
|
||||
EffectiveTo: input.EffectiveTo,
|
||||
})
|
||||
}
|
||||
return items
|
||||
|
||||
@ -1,27 +0,0 @@
|
||||
package store
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestValidateEffectivePricingRuleShape(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
resource string
|
||||
unit string
|
||||
calculator string
|
||||
valid bool
|
||||
}{
|
||||
{name: "text", resource: "text_input", unit: "1k_tokens", calculator: "token_usage", valid: true},
|
||||
{name: "image", resource: "image", unit: "image", calculator: "unit_weight", valid: true},
|
||||
{name: "video", resource: "video", unit: "5s", calculator: "duration_weight", valid: true},
|
||||
{name: "wrong video unit", resource: "video", unit: "second", calculator: "duration_weight"},
|
||||
{name: "wrong image calculator", resource: "image", unit: "image", calculator: "token_usage"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
err := ValidateEffectivePricingRuleShape(test.resource, test.unit, test.calculator)
|
||||
if (err == nil) != test.valid {
|
||||
t.Fatalf("valid=%v err=%v", test.valid, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -223,24 +223,21 @@ type RateLimitResult struct {
|
||||
}
|
||||
|
||||
type CreateTaskAttemptInput struct {
|
||||
TaskID string
|
||||
AttemptNo int
|
||||
PlatformID string
|
||||
PlatformModelID string
|
||||
ClientID string
|
||||
QueueKey string
|
||||
Status string
|
||||
Simulated bool
|
||||
RequestSnapshot map[string]any
|
||||
Metrics map[string]any
|
||||
PricingSnapshot map[string]any
|
||||
RequestFingerprint string
|
||||
TaskID string
|
||||
AttemptNo int
|
||||
PlatformID string
|
||||
PlatformModelID string
|
||||
ClientID string
|
||||
QueueKey string
|
||||
Status string
|
||||
Simulated bool
|
||||
RequestSnapshot map[string]any
|
||||
Metrics map[string]any
|
||||
}
|
||||
|
||||
type AsyncTaskQueueItem struct {
|
||||
ID string
|
||||
Priority int
|
||||
NextRunAt time.Time
|
||||
ID string
|
||||
Priority int
|
||||
}
|
||||
|
||||
type FinishTaskAttemptInput struct {
|
||||
@ -259,37 +256,15 @@ type FinishTaskAttemptInput struct {
|
||||
}
|
||||
|
||||
type FinishTaskSuccessInput struct {
|
||||
TaskID string
|
||||
ExecutionToken string
|
||||
AttemptID string
|
||||
Result map[string]any
|
||||
Billings []any
|
||||
RequestID string
|
||||
ResolvedModel string
|
||||
Usage map[string]any
|
||||
Metrics map[string]any
|
||||
BillingSummary map[string]any
|
||||
FinalChargeAmount float64
|
||||
FinalChargeAmountText string
|
||||
BillingCurrency string
|
||||
PricingSnapshot map[string]any
|
||||
RequestFingerprint string
|
||||
ResponseStartedAt time.Time
|
||||
ResponseFinishedAt time.Time
|
||||
ResponseDurationMS int64
|
||||
}
|
||||
|
||||
type FinishTaskManualReviewInput struct {
|
||||
TaskID string
|
||||
ExecutionToken string
|
||||
AttemptID string
|
||||
TaskStatus string
|
||||
Code string
|
||||
Message string
|
||||
Result map[string]any
|
||||
Billings []any
|
||||
RequestID string
|
||||
PricingSnapshot map[string]any
|
||||
RequestFingerprint string
|
||||
ResolvedModel string
|
||||
Usage map[string]any
|
||||
Metrics map[string]any
|
||||
BillingSummary map[string]any
|
||||
FinalChargeAmount float64
|
||||
ResponseStartedAt time.Time
|
||||
ResponseFinishedAt time.Time
|
||||
ResponseDurationMS int64
|
||||
@ -297,7 +272,6 @@ type FinishTaskManualReviewInput struct {
|
||||
|
||||
type FinishTaskFailureInput struct {
|
||||
TaskID string
|
||||
ExecutionToken string
|
||||
Code string
|
||||
Message string
|
||||
Result map[string]any
|
||||
|
||||
@ -3,7 +3,6 @@ package store
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
@ -153,163 +152,18 @@ func nullableTaskListTime(value *time.Time) any {
|
||||
return *value
|
||||
}
|
||||
|
||||
func (s *Store) ClaimTaskExecution(ctx context.Context, taskID string, executionToken string, leaseTTL time.Duration) (GatewayTask, error) {
|
||||
if leaseTTL <= 0 {
|
||||
leaseTTL = 5 * time.Minute
|
||||
}
|
||||
var task GatewayTask
|
||||
manualReview := false
|
||||
err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
|
||||
var queuedReady bool
|
||||
var runningExpired bool
|
||||
var production bool
|
||||
var hasGatewayUser bool
|
||||
if err := tx.QueryRow(ctx, `
|
||||
SELECT status = 'queued' AND next_run_at <= now(),
|
||||
status = 'running' AND (execution_lease_expires_at IS NULL OR execution_lease_expires_at <= now()),
|
||||
run_mode = 'production',
|
||||
gateway_user_id IS NOT NULL
|
||||
FROM gateway_tasks
|
||||
WHERE id = $1::uuid
|
||||
FOR UPDATE`, taskID).Scan(&queuedReady, &runningExpired, &production, &hasGatewayUser); err != nil {
|
||||
return err
|
||||
}
|
||||
if !queuedReady && !runningExpired {
|
||||
return ErrTaskExecutionLeaseUnavailable
|
||||
}
|
||||
if runningExpired && production {
|
||||
var submissionAmbiguous bool
|
||||
if err := tx.QueryRow(ctx, `
|
||||
SELECT COALESCE((
|
||||
SELECT (
|
||||
(status = 'running' AND upstream_submission_status IN ('submitting', 'response_received'))
|
||||
OR (status = 'failed' AND upstream_submission_status = 'submitting')
|
||||
)
|
||||
FROM gateway_task_attempts
|
||||
WHERE task_id = $1::uuid
|
||||
ORDER BY attempt_no DESC, started_at DESC
|
||||
LIMIT 1
|
||||
), false)`, taskID).Scan(&submissionAmbiguous); err != nil {
|
||||
return err
|
||||
}
|
||||
if submissionAmbiguous {
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE gateway_tasks
|
||||
SET status = 'failed',
|
||||
billing_status = CASE WHEN $2 THEN 'manual_review' ELSE 'not_required' END,
|
||||
billing_updated_at = now(),
|
||||
error = 'upstream submission result is unknown',
|
||||
error_code = 'upstream_submission_unknown',
|
||||
error_message = 'upstream submission result is unknown',
|
||||
locked_by = NULL,
|
||||
locked_at = NULL,
|
||||
heartbeat_at = NULL,
|
||||
execution_token = NULL,
|
||||
execution_lease_expires_at = NULL,
|
||||
finished_at = now(),
|
||||
updated_at = now()
|
||||
WHERE id = $1::uuid`, taskID, hasGatewayUser); err != nil {
|
||||
return err
|
||||
}
|
||||
if hasGatewayUser {
|
||||
payloadJSON, _ := json.Marshal(map[string]any{
|
||||
"taskId": taskID, "classification": "upstream_submission_unknown",
|
||||
})
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO settlement_outbox (
|
||||
task_id, event_type, action, amount, currency, pricing_snapshot, payload,
|
||||
status, next_attempt_at, manual_review_reason
|
||||
)
|
||||
SELECT id, 'task.billing.review', 'release', reservation_amount, billing_currency,
|
||||
pricing_snapshot, $2::jsonb, 'manual_review', now(), 'upstream_submission_unknown'
|
||||
FROM gateway_tasks
|
||||
WHERE id = $1::uuid
|
||||
ON CONFLICT (task_id, event_type) DO NOTHING`, taskID, string(payloadJSON)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
manualReview = true
|
||||
return nil
|
||||
}
|
||||
}
|
||||
var err error
|
||||
task, err = scanGatewayTask(tx.QueryRow(ctx, `
|
||||
func (s *Store) MarkTaskRunning(ctx context.Context, taskID string, modelType string, normalizedRequest map[string]any) error {
|
||||
normalizedJSON, _ := json.Marshal(emptyObjectIfNil(normalizedRequest))
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
UPDATE gateway_tasks
|
||||
SET status = 'running',
|
||||
execution_token = $2::uuid,
|
||||
execution_lease_expires_at = now() + ($3::int * interval '1 second'),
|
||||
model_type = NULLIF($2::text, ''),
|
||||
normalized_request = $3::jsonb,
|
||||
locked_at = now(),
|
||||
heartbeat_at = now(),
|
||||
updated_at = now()
|
||||
WHERE id = $1::uuid
|
||||
AND (
|
||||
(status = 'queued' AND next_run_at <= now())
|
||||
OR (status = 'running' AND (execution_lease_expires_at IS NULL OR execution_lease_expires_at <= now()))
|
||||
)
|
||||
RETURNING `+gatewayTaskColumns, taskID, executionToken, int(leaseTTL/time.Second)))
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return GatewayTask{}, ErrTaskExecutionLeaseUnavailable
|
||||
}
|
||||
return GatewayTask{}, err
|
||||
}
|
||||
if manualReview {
|
||||
return GatewayTask{}, ErrTaskExecutionManualReview
|
||||
}
|
||||
return task, nil
|
||||
}
|
||||
|
||||
func (s *Store) RenewTaskExecutionLease(ctx context.Context, taskID string, executionToken string, leaseTTL time.Duration) error {
|
||||
if leaseTTL <= 0 {
|
||||
leaseTTL = 5 * time.Minute
|
||||
}
|
||||
tag, err := s.pool.Exec(ctx, `
|
||||
UPDATE gateway_tasks
|
||||
SET execution_lease_expires_at = now() + ($3::int * interval '1 second'),
|
||||
heartbeat_at = now(),
|
||||
updated_at = now()
|
||||
WHERE id = $1::uuid
|
||||
AND status = 'running'
|
||||
AND execution_token = $2::uuid`, taskID, executionToken, int(leaseTTL/time.Second))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() != 1 {
|
||||
var stillRunning bool
|
||||
if err := s.pool.QueryRow(ctx, `
|
||||
SELECT COALESCE((SELECT status = 'running' FROM gateway_tasks WHERE id = $1::uuid), false)`, taskID).Scan(&stillRunning); err != nil {
|
||||
return err
|
||||
}
|
||||
if !stillRunning {
|
||||
return ErrTaskExecutionFinished
|
||||
}
|
||||
return ErrTaskExecutionLeaseLost
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) MarkTaskRunning(ctx context.Context, taskID string, executionToken string, modelType string, normalizedRequest map[string]any) error {
|
||||
normalizedJSON, _ := json.Marshal(emptyObjectIfNil(normalizedRequest))
|
||||
tag, err := s.pool.Exec(ctx, `
|
||||
UPDATE gateway_tasks
|
||||
SET status = 'running',
|
||||
model_type = NULLIF($3::text, ''),
|
||||
normalized_request = $4::jsonb,
|
||||
locked_at = now(),
|
||||
heartbeat_at = now(),
|
||||
updated_at = now()
|
||||
WHERE id = $1::uuid
|
||||
AND status = 'running'
|
||||
AND execution_token = $2::uuid`, taskID, executionToken, modelType, string(normalizedJSON))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() != 1 {
|
||||
return ErrTaskExecutionLeaseLost
|
||||
}
|
||||
return nil
|
||||
WHERE id = $1::uuid`, taskID, modelType, string(normalizedJSON))
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) ClaimAsyncQueuedTask(ctx context.Context, workerID string) (GatewayTask, error) {
|
||||
@ -335,7 +189,7 @@ WHERE t.id = picked.task_id
|
||||
RETURNING `+gatewayTaskColumns, workerID))
|
||||
}
|
||||
|
||||
func (s *Store) RequeueTask(ctx context.Context, taskID string, executionToken string, delay time.Duration, queueKey string) (GatewayTask, error) {
|
||||
func (s *Store) RequeueTask(ctx context.Context, taskID string, delay time.Duration, queueKey string) (GatewayTask, error) {
|
||||
if delay < time.Second {
|
||||
delay = time.Second
|
||||
}
|
||||
@ -349,18 +203,14 @@ SET status = 'queued',
|
||||
locked_by = NULL,
|
||||
locked_at = NULL,
|
||||
heartbeat_at = NULL,
|
||||
execution_token = NULL,
|
||||
execution_lease_expires_at = NULL,
|
||||
next_run_at = $3::timestamptz,
|
||||
queue_key = COALESCE(NULLIF($4::text, ''), queue_key),
|
||||
next_run_at = $2::timestamptz,
|
||||
queue_key = COALESCE(NULLIF($3::text, ''), queue_key),
|
||||
error = NULL,
|
||||
error_code = NULL,
|
||||
error_message = NULL,
|
||||
updated_at = now()
|
||||
WHERE id = $1::uuid
|
||||
AND status = 'running'
|
||||
AND execution_token = $2::uuid
|
||||
RETURNING `+gatewayTaskColumns, taskID, executionToken, nextRunAt, strings.TrimSpace(queueKey)))
|
||||
RETURNING `+gatewayTaskColumns, taskID, nextRunAt, strings.TrimSpace(queueKey)))
|
||||
}
|
||||
|
||||
func (s *Store) SetTaskRiverJobID(ctx context.Context, taskID string, riverJobID int64) error {
|
||||
@ -375,32 +225,25 @@ WHERE id = $1::uuid`, taskID, riverJobID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) SetTaskRemoteTask(ctx context.Context, taskID string, executionToken string, attemptID string, remoteTaskID string, payload map[string]any) error {
|
||||
func (s *Store) SetTaskRemoteTask(ctx context.Context, taskID string, attemptID string, remoteTaskID string, payload map[string]any) error {
|
||||
payloadJSON, _ := json.Marshal(emptyObjectIfNil(payload))
|
||||
return pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
|
||||
tag, err := tx.Exec(ctx, `
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE gateway_tasks
|
||||
SET remote_task_id = NULLIF($3::text, ''),
|
||||
remote_task_payload = $4::jsonb,
|
||||
SET remote_task_id = NULLIF($2::text, ''),
|
||||
remote_task_payload = $3::jsonb,
|
||||
updated_at = now()
|
||||
WHERE id = $1::uuid
|
||||
AND status = 'running'
|
||||
AND execution_token = $2::uuid`,
|
||||
WHERE id = $1::uuid`,
|
||||
taskID,
|
||||
executionToken,
|
||||
remoteTaskID,
|
||||
string(payloadJSON),
|
||||
)
|
||||
if err != nil {
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() != 1 {
|
||||
return ErrTaskExecutionLeaseLost
|
||||
}
|
||||
if strings.TrimSpace(attemptID) == "" {
|
||||
return nil
|
||||
}
|
||||
_, err = tx.Exec(ctx, `
|
||||
_, err := tx.Exec(ctx, `
|
||||
UPDATE gateway_task_attempts
|
||||
SET remote_task_id = NULLIF($2::text, ''),
|
||||
response_snapshot = COALESCE(response_snapshot, '{}'::jsonb) || jsonb_build_object('remote_task_payload', $3::jsonb)
|
||||
@ -413,20 +256,6 @@ WHERE id = $1::uuid`,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Store) SetAttemptUpstreamSubmissionStatus(ctx context.Context, attemptID string, status string) error {
|
||||
switch status {
|
||||
case "not_submitted", "submitting", "response_received":
|
||||
default:
|
||||
return fmt.Errorf("invalid upstream submission status %q", status)
|
||||
}
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
UPDATE gateway_task_attempts
|
||||
SET upstream_submission_status = $2,
|
||||
upstream_submission_updated_at = now()
|
||||
WHERE id = $1::uuid`, attemptID, status)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) CancelQueuedTask(ctx context.Context, taskID string, message string) (GatewayTask, bool, error) {
|
||||
message = strings.TrimSpace(message)
|
||||
if message == "" {
|
||||
@ -464,13 +293,10 @@ func (s *Store) ListRecoverableAsyncTasks(ctx context.Context, limit int) ([]Asy
|
||||
limit = 500
|
||||
}
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT id::text, priority, next_run_at
|
||||
SELECT id::text, priority
|
||||
FROM gateway_tasks
|
||||
WHERE async_mode = true
|
||||
AND (
|
||||
status = 'queued'
|
||||
OR (status = 'running' AND (execution_lease_expires_at IS NULL OR execution_lease_expires_at <= now()))
|
||||
)
|
||||
AND status IN ('queued', 'running')
|
||||
ORDER BY priority ASC, created_at ASC
|
||||
LIMIT $1`, limit)
|
||||
if err != nil {
|
||||
@ -480,7 +306,7 @@ LIMIT $1`, limit)
|
||||
items := make([]AsyncTaskQueueItem, 0)
|
||||
for rows.Next() {
|
||||
var item AsyncTaskQueueItem
|
||||
if err := rows.Scan(&item.ID, &item.Priority, &item.NextRunAt); err != nil {
|
||||
if err := rows.Scan(&item.ID, &item.Priority); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, item)
|
||||
@ -494,7 +320,6 @@ LIMIT $1`, limit)
|
||||
func (s *Store) CreateTaskAttempt(ctx context.Context, input CreateTaskAttemptInput) (string, error) {
|
||||
requestJSON, _ := json.Marshal(emptyObjectIfNil(input.RequestSnapshot))
|
||||
metricsJSON, _ := json.Marshal(emptyObjectIfNil(input.Metrics))
|
||||
pricingJSON, _ := json.Marshal(emptyObjectIfNil(input.PricingSnapshot))
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
@ -505,13 +330,11 @@ func (s *Store) CreateTaskAttempt(ctx context.Context, input CreateTaskAttemptIn
|
||||
err = tx.QueryRow(ctx, `
|
||||
INSERT INTO gateway_task_attempts (
|
||||
task_id, attempt_no, platform_id, platform_model_id, client_id, queue_key,
|
||||
status, simulated, request_snapshot, metrics, pricing_snapshot, request_fingerprint,
|
||||
upstream_submission_status, upstream_submission_updated_at
|
||||
status, simulated, request_snapshot, metrics
|
||||
)
|
||||
VALUES (
|
||||
$1::uuid, $2::int, NULLIF($3::text, '')::uuid, NULLIF($4::text, '')::uuid, NULLIF($5::text, ''), $6,
|
||||
$7, $8, $9::jsonb, $10::jsonb, $11::jsonb, NULLIF($12, ''),
|
||||
'not_submitted', now()
|
||||
$7, $8, $9::jsonb, $10::jsonb
|
||||
)
|
||||
RETURNING id::text`,
|
||||
input.TaskID,
|
||||
@ -524,8 +347,6 @@ RETURNING id::text`,
|
||||
input.Simulated,
|
||||
string(requestJSON),
|
||||
string(metricsJSON),
|
||||
string(pricingJSON),
|
||||
input.RequestFingerprint,
|
||||
).Scan(&attemptID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
@ -668,8 +489,6 @@ SELECT a.id::text, a.task_id::text, a.attempt_no,
|
||||
a.request_snapshot, COALESCE(a.response_snapshot, '{}'::jsonb),
|
||||
COALESCE(a.response_started_at::text, ''), COALESCE(a.response_finished_at::text, ''),
|
||||
COALESCE(a.response_duration_ms, 0), COALESCE(a.error_code, ''), COALESCE(a.error_message, ''),
|
||||
COALESCE(a.pricing_snapshot, '{}'::jsonb), COALESCE(a.request_fingerprint, ''),
|
||||
a.upstream_submission_status, COALESCE(a.upstream_submission_updated_at::text, ''),
|
||||
a.started_at, COALESCE(a.finished_at::text, '')
|
||||
FROM gateway_task_attempts a
|
||||
LEFT JOIN integration_platforms p ON p.id = a.platform_id
|
||||
@ -699,7 +518,6 @@ func scanTaskAttempt(scanner taskScanner) (TaskAttempt, error) {
|
||||
var metricsBytes []byte
|
||||
var requestBytes []byte
|
||||
var responseBytes []byte
|
||||
var pricingSnapshotBytes []byte
|
||||
if err := scanner.Scan(
|
||||
&item.ID,
|
||||
&item.TaskID,
|
||||
@ -726,10 +544,6 @@ func scanTaskAttempt(scanner taskScanner) (TaskAttempt, error) {
|
||||
&item.ResponseDurationMS,
|
||||
&item.ErrorCode,
|
||||
&item.ErrorMessage,
|
||||
&pricingSnapshotBytes,
|
||||
&item.RequestFingerprint,
|
||||
&item.UpstreamSubmissionStatus,
|
||||
&item.UpstreamSubmissionUpdatedAt,
|
||||
&item.StartedAt,
|
||||
&item.FinishedAt,
|
||||
); err != nil {
|
||||
@ -739,7 +553,6 @@ func scanTaskAttempt(scanner taskScanner) (TaskAttempt, error) {
|
||||
item.Metrics = decodeObject(metricsBytes)
|
||||
item.RequestSnapshot = decodeObject(requestBytes)
|
||||
item.ResponseSnapshot = decodeObject(responseBytes)
|
||||
item.PricingSnapshot = decodeObject(pricingSnapshotBytes)
|
||||
enrichTaskAttemptFromMetrics(&item)
|
||||
return item, nil
|
||||
}
|
||||
@ -857,41 +670,7 @@ func (s *Store) FinishTaskSuccess(ctx context.Context, input FinishTaskSuccessIn
|
||||
usageJSON, _ := json.Marshal(emptyObjectIfNil(input.Usage))
|
||||
metricsJSON, _ := json.Marshal(emptyObjectIfNil(input.Metrics))
|
||||
billingSummaryJSON, _ := json.Marshal(emptyObjectIfNil(input.BillingSummary))
|
||||
pricingSnapshotJSON, _ := json.Marshal(emptyObjectIfNil(input.PricingSnapshot))
|
||||
finalChargeAmount := strings.TrimSpace(input.FinalChargeAmountText)
|
||||
if finalChargeAmount == "" {
|
||||
finalChargeAmount = strconv.FormatFloat(input.FinalChargeAmount, 'f', 9, 64)
|
||||
}
|
||||
currency := normalizeWalletCurrency(input.BillingCurrency)
|
||||
err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
|
||||
if strings.TrimSpace(input.AttemptID) != "" {
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE gateway_task_attempts
|
||||
SET status = 'succeeded',
|
||||
retryable = false,
|
||||
request_id = NULLIF($2, ''),
|
||||
usage = $3::jsonb,
|
||||
metrics = $4::jsonb,
|
||||
response_snapshot = $5::jsonb,
|
||||
pricing_snapshot = $6::jsonb,
|
||||
request_fingerprint = NULLIF($7, ''),
|
||||
upstream_submission_status = 'response_received',
|
||||
upstream_submission_updated_at = now(),
|
||||
response_started_at = $8::timestamptz,
|
||||
response_finished_at = $9::timestamptz,
|
||||
response_duration_ms = $10,
|
||||
error_code = NULL,
|
||||
error_message = NULL,
|
||||
finished_at = now()
|
||||
WHERE id = $1::uuid`,
|
||||
input.AttemptID, input.RequestID, string(usageJSON), string(metricsJSON),
|
||||
string(resultJSON), string(pricingSnapshotJSON), input.RequestFingerprint,
|
||||
nullableTime(input.ResponseStartedAt), nullableTime(input.ResponseFinishedAt), input.ResponseDurationMS,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
tag, err := tx.Exec(ctx, `
|
||||
if _, err := s.pool.Exec(ctx, `
|
||||
UPDATE gateway_tasks
|
||||
SET status = 'succeeded',
|
||||
result = $2::jsonb,
|
||||
@ -901,156 +680,32 @@ SET status = 'succeeded',
|
||||
usage = $6::jsonb,
|
||||
metrics = $7::jsonb,
|
||||
billing_summary = $8::jsonb,
|
||||
final_charge_amount = $9::numeric,
|
||||
billing_version = 'effective-pricing-v2',
|
||||
billing_status = CASE
|
||||
WHEN run_mode = 'production' AND gateway_user_id IS NOT NULL THEN 'pending'
|
||||
ELSE 'not_required'
|
||||
END,
|
||||
billing_currency = $10,
|
||||
pricing_snapshot = $11::jsonb,
|
||||
request_fingerprint = NULLIF($12, ''),
|
||||
billing_updated_at = now(),
|
||||
response_started_at = $13::timestamptz,
|
||||
response_finished_at = $14::timestamptz,
|
||||
response_duration_ms = $15,
|
||||
final_charge_amount = $9,
|
||||
response_started_at = $10::timestamptz,
|
||||
response_finished_at = $11::timestamptz,
|
||||
response_duration_ms = $12,
|
||||
error = NULL,
|
||||
error_code = NULL,
|
||||
error_message = NULL,
|
||||
locked_by = NULL,
|
||||
locked_at = NULL,
|
||||
heartbeat_at = NULL,
|
||||
execution_token = NULL,
|
||||
execution_lease_expires_at = NULL,
|
||||
finished_at = now(),
|
||||
updated_at = now()
|
||||
WHERE id = $1::uuid
|
||||
AND status = 'running'
|
||||
AND execution_token = $16::uuid`,
|
||||
input.TaskID,
|
||||
string(resultJSON),
|
||||
string(billingsJSON),
|
||||
input.RequestID,
|
||||
input.ResolvedModel,
|
||||
string(usageJSON),
|
||||
string(metricsJSON),
|
||||
string(billingSummaryJSON),
|
||||
finalChargeAmount,
|
||||
currency,
|
||||
string(pricingSnapshotJSON),
|
||||
input.RequestFingerprint,
|
||||
nullableTime(input.ResponseStartedAt),
|
||||
nullableTime(input.ResponseFinishedAt),
|
||||
input.ResponseDurationMS,
|
||||
input.ExecutionToken,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() != 1 {
|
||||
return ErrTaskExecutionLeaseLost
|
||||
}
|
||||
payloadJSON, _ := json.Marshal(map[string]any{"taskId": input.TaskID, "pricingVersion": "effective-pricing-v2"})
|
||||
_, err = tx.Exec(ctx, `
|
||||
INSERT INTO settlement_outbox (
|
||||
task_id, event_type, action, amount, currency, pricing_snapshot, payload, status, next_attempt_at
|
||||
)
|
||||
SELECT id, 'task.billing.settle', 'settle', $2::numeric, $3, $4::jsonb, $5::jsonb, 'pending', now()
|
||||
FROM gateway_tasks
|
||||
WHERE id = $1::uuid
|
||||
AND run_mode = 'production'
|
||||
AND gateway_user_id IS NOT NULL
|
||||
ON CONFLICT (task_id, event_type) DO NOTHING`,
|
||||
input.TaskID, finalChargeAmount, currency, string(pricingSnapshotJSON), string(payloadJSON))
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
return GatewayTask{}, err
|
||||
}
|
||||
return s.GetTask(ctx, input.TaskID)
|
||||
}
|
||||
|
||||
func (s *Store) FinishTaskManualReview(ctx context.Context, input FinishTaskManualReviewInput) (GatewayTask, error) {
|
||||
status := strings.TrimSpace(input.TaskStatus)
|
||||
if status != "succeeded" && status != "failed" {
|
||||
return GatewayTask{}, fmt.Errorf("manual review task status must be succeeded or failed")
|
||||
}
|
||||
resultJSON, _ := json.Marshal(emptyObjectIfNil(input.Result))
|
||||
pricingSnapshotJSON, _ := json.Marshal(emptyObjectIfNil(input.PricingSnapshot))
|
||||
err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
|
||||
if strings.TrimSpace(input.AttemptID) != "" {
|
||||
attemptStatus := "failed"
|
||||
if status == "succeeded" {
|
||||
attemptStatus = "succeeded"
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE gateway_task_attempts
|
||||
SET status = $2,
|
||||
request_id = NULLIF($3, ''),
|
||||
error_code = CASE WHEN $2 = 'failed' THEN NULLIF($4, '') ELSE NULL END,
|
||||
error_message = CASE WHEN $2 = 'failed' THEN NULLIF($5, '') ELSE NULL END,
|
||||
upstream_submission_status = CASE WHEN $2 = 'succeeded' THEN 'response_received' ELSE upstream_submission_status END,
|
||||
upstream_submission_updated_at = now(),
|
||||
response_started_at = $6::timestamptz,
|
||||
response_finished_at = $7::timestamptz,
|
||||
response_duration_ms = $8,
|
||||
finished_at = now()
|
||||
WHERE id = $1::uuid`, input.AttemptID, attemptStatus, input.RequestID, input.Code, input.Message,
|
||||
nullableTime(input.ResponseStartedAt), nullableTime(input.ResponseFinishedAt), input.ResponseDurationMS); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
tag, err := tx.Exec(ctx, `
|
||||
UPDATE gateway_tasks
|
||||
SET status = $2,
|
||||
result = $3::jsonb,
|
||||
request_id = NULLIF($4, ''),
|
||||
error = CASE WHEN $2 = 'failed' THEN NULLIF($6, '') ELSE NULL END,
|
||||
error_code = NULLIF($5, ''),
|
||||
error_message = NULLIF($6, ''),
|
||||
billing_status = 'manual_review',
|
||||
pricing_snapshot = CASE WHEN $7::jsonb = '{}'::jsonb THEN pricing_snapshot ELSE $7::jsonb END,
|
||||
request_fingerprint = COALESCE(NULLIF($8, ''), request_fingerprint),
|
||||
billing_updated_at = now(),
|
||||
response_started_at = $9::timestamptz,
|
||||
response_finished_at = $10::timestamptz,
|
||||
response_duration_ms = $11,
|
||||
locked_by = NULL,
|
||||
locked_at = NULL,
|
||||
heartbeat_at = NULL,
|
||||
execution_token = NULL,
|
||||
execution_lease_expires_at = NULL,
|
||||
finished_at = now(),
|
||||
updated_at = now()
|
||||
WHERE id = $1::uuid
|
||||
AND status = 'running'
|
||||
AND execution_token = $12::uuid`, input.TaskID, status, string(resultJSON), input.RequestID, input.Code, input.Message,
|
||||
string(pricingSnapshotJSON), input.RequestFingerprint, nullableTime(input.ResponseStartedAt),
|
||||
nullableTime(input.ResponseFinishedAt), input.ResponseDurationMS, input.ExecutionToken)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() != 1 {
|
||||
return ErrTaskExecutionLeaseLost
|
||||
}
|
||||
payloadJSON, _ := json.Marshal(map[string]any{
|
||||
"taskId": input.TaskID, "classification": input.Code,
|
||||
})
|
||||
_, err = tx.Exec(ctx, `
|
||||
INSERT INTO settlement_outbox (
|
||||
task_id, event_type, action, amount, currency, pricing_snapshot, payload,
|
||||
status, next_attempt_at, manual_review_reason
|
||||
)
|
||||
SELECT id, 'task.billing.review', 'release', reservation_amount, billing_currency,
|
||||
pricing_snapshot, $2::jsonb, 'manual_review', now(), $3
|
||||
FROM gateway_tasks
|
||||
WHERE id = $1::uuid
|
||||
AND run_mode = 'production'
|
||||
AND gateway_user_id IS NOT NULL
|
||||
ON CONFLICT (task_id, event_type) DO NOTHING`, input.TaskID, string(payloadJSON), input.Code)
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
WHERE id = $1::uuid`,
|
||||
input.TaskID,
|
||||
string(resultJSON),
|
||||
string(billingsJSON),
|
||||
input.RequestID,
|
||||
input.ResolvedModel,
|
||||
string(usageJSON),
|
||||
string(metricsJSON),
|
||||
string(billingSummaryJSON),
|
||||
input.FinalChargeAmount,
|
||||
nullableTime(input.ResponseStartedAt),
|
||||
nullableTime(input.ResponseFinishedAt),
|
||||
input.ResponseDurationMS,
|
||||
); err != nil {
|
||||
return GatewayTask{}, err
|
||||
}
|
||||
return s.GetTask(ctx, input.TaskID)
|
||||
@ -1088,9 +743,6 @@ ON CONFLICT (gateway_user_id, currency) DO NOTHING`,
|
||||
task.GatewayTenantID, task.GatewayUserID, task.TenantID, task.TenantKey, task.UserID, currency); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ensureWalletAccountAuditGuard(ctx, tx, task.GatewayUserID, currency); err != nil {
|
||||
return err
|
||||
}
|
||||
var exists bool
|
||||
var accountID string
|
||||
var balanceBefore float64
|
||||
@ -1207,8 +859,7 @@ func taskBillingString(value any) string {
|
||||
func (s *Store) FinishTaskFailure(ctx context.Context, input FinishTaskFailureInput) (GatewayTask, error) {
|
||||
metricsJSON, _ := json.Marshal(emptyObjectIfNil(input.Metrics))
|
||||
resultJSON, _ := json.Marshal(emptyObjectIfNil(input.Result))
|
||||
err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
|
||||
tag, err := tx.Exec(ctx, `
|
||||
if _, err := s.pool.Exec(ctx, `
|
||||
UPDATE gateway_tasks
|
||||
SET status = 'failed',
|
||||
error = NULLIF($2::text, ''),
|
||||
@ -1220,55 +871,22 @@ func (s *Store) FinishTaskFailure(ctx context.Context, input FinishTaskFailureIn
|
||||
response_finished_at = $7::timestamptz,
|
||||
response_duration_ms = $8,
|
||||
result = $9::jsonb,
|
||||
billing_status = CASE
|
||||
WHEN run_mode <> 'production' OR gateway_user_id IS NULL THEN 'not_required'
|
||||
WHEN reservation_amount > 0 THEN 'pending'
|
||||
ELSE 'released'
|
||||
END,
|
||||
billing_updated_at = now(),
|
||||
locked_by = NULL,
|
||||
locked_at = NULL,
|
||||
heartbeat_at = NULL,
|
||||
execution_token = NULL,
|
||||
execution_lease_expires_at = NULL,
|
||||
finished_at = now(),
|
||||
updated_at = now()
|
||||
WHERE id = $1::uuid
|
||||
AND status = 'running'
|
||||
AND execution_token = $10::uuid`,
|
||||
input.TaskID,
|
||||
input.Message,
|
||||
input.Code,
|
||||
input.RequestID,
|
||||
string(metricsJSON),
|
||||
nullableTime(input.ResponseStartedAt),
|
||||
nullableTime(input.ResponseFinishedAt),
|
||||
input.ResponseDurationMS,
|
||||
string(resultJSON),
|
||||
input.ExecutionToken,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() != 1 {
|
||||
return ErrTaskExecutionLeaseLost
|
||||
}
|
||||
payloadJSON, _ := json.Marshal(map[string]any{"taskId": input.TaskID, "reason": input.Code})
|
||||
_, err = tx.Exec(ctx, `
|
||||
INSERT INTO settlement_outbox (
|
||||
task_id, event_type, action, amount, currency, pricing_snapshot, payload, status, next_attempt_at
|
||||
)
|
||||
SELECT id, 'task.billing.release', 'release', reservation_amount, billing_currency,
|
||||
pricing_snapshot, $2::jsonb, 'pending', now()
|
||||
FROM gateway_tasks
|
||||
WHERE id = $1::uuid
|
||||
AND run_mode = 'production'
|
||||
AND gateway_user_id IS NOT NULL
|
||||
AND reservation_amount > 0
|
||||
ON CONFLICT (task_id, event_type) DO NOTHING`, input.TaskID, string(payloadJSON))
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
WHERE id = $1::uuid`,
|
||||
input.TaskID,
|
||||
input.Message,
|
||||
input.Code,
|
||||
input.RequestID,
|
||||
string(metricsJSON),
|
||||
nullableTime(input.ResponseStartedAt),
|
||||
nullableTime(input.ResponseFinishedAt),
|
||||
input.ResponseDurationMS,
|
||||
string(resultJSON),
|
||||
); err != nil {
|
||||
return GatewayTask{}, err
|
||||
}
|
||||
return s.GetTask(ctx, input.TaskID)
|
||||
|
||||
@ -3,7 +3,6 @@ package store
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
@ -83,7 +82,6 @@ type WalletBalanceAdjustmentInput struct {
|
||||
GatewayUserID string `json:"gatewayUserId"`
|
||||
Currency string `json:"currency"`
|
||||
Balance float64 `json:"balance"`
|
||||
BalanceText string `json:"-"`
|
||||
Reason string `json:"reason"`
|
||||
IdempotencyKey string `json:"idempotencyKey"`
|
||||
Metadata map[string]any `json:"metadata"`
|
||||
@ -93,7 +91,6 @@ type WalletRechargeInput struct {
|
||||
GatewayUserID string `json:"gatewayUserId"`
|
||||
Currency string `json:"currency"`
|
||||
Amount float64 `json:"amount"`
|
||||
AmountText string `json:"-"`
|
||||
Reason string `json:"reason"`
|
||||
IdempotencyKey string `json:"idempotencyKey"`
|
||||
Metadata map[string]any `json:"metadata"`
|
||||
@ -138,7 +135,7 @@ func (s *Store) WalletAvailability(ctx context.Context, user *auth.User, currenc
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *Store) ReserveTaskBilling(ctx context.Context, task GatewayTask, user *auth.User, billings []any, pricingSnapshots ...map[string]any) ([]WalletBillingReservation, error) {
|
||||
func (s *Store) ReserveTaskBilling(ctx context.Context, task GatewayTask, user *auth.User, billings []any) ([]WalletBillingReservation, error) {
|
||||
gatewayUserID := taskGatewayUserID(task, user)
|
||||
if gatewayUserID == "" {
|
||||
return nil, nil
|
||||
@ -148,21 +145,12 @@ func (s *Store) ReserveTaskBilling(ctx context.Context, task GatewayTask, user *
|
||||
return nil, fmt.Errorf("task id is required for wallet reservation")
|
||||
}
|
||||
|
||||
pricingSnapshot := map[string]any{}
|
||||
if len(pricingSnapshots) > 0 {
|
||||
pricingSnapshot = emptyObjectIfNil(pricingSnapshots[0])
|
||||
}
|
||||
if exactAmount := walletString(pricingSnapshot["reservationAmount"]); exactAmount != "" {
|
||||
return s.reserveTaskBillingExact(ctx, task, gatewayUserID, exactAmount, pricingSnapshot)
|
||||
}
|
||||
amounts := walletBillingAmounts(billings)
|
||||
if len(amounts) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
reservations := make([]WalletBillingReservation, 0, len(amounts))
|
||||
pricingSnapshotJSON, _ := json.Marshal(pricingSnapshot)
|
||||
requestFingerprint := walletString(pricingSnapshot["requestFingerprint"])
|
||||
err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
|
||||
for currency, rawAmount := range amounts {
|
||||
amount := roundMoney(rawAmount)
|
||||
@ -269,30 +257,7 @@ VALUES (
|
||||
}
|
||||
reservations = append(reservations, reservation)
|
||||
}
|
||||
_, err := tx.Exec(ctx, `
|
||||
UPDATE gateway_tasks task
|
||||
SET reservation_amount = COALESCE((
|
||||
SELECT SUM(reserve.amount)
|
||||
FROM gateway_wallet_transactions reserve
|
||||
WHERE reserve.reference_type = 'gateway_task'
|
||||
AND reserve.reference_id = task.id::text
|
||||
AND reserve.transaction_type = 'reserve'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM gateway_wallet_transactions release
|
||||
WHERE release.account_id = reserve.account_id
|
||||
AND release.transaction_type = 'release'
|
||||
AND release.idempotency_key = reserve.idempotency_key || ':release'
|
||||
)
|
||||
), 0),
|
||||
billing_status = 'pending',
|
||||
billing_currency = 'resource',
|
||||
pricing_snapshot = $2::jsonb,
|
||||
request_fingerprint = NULLIF($3, ''),
|
||||
billing_updated_at = now(),
|
||||
updated_at = now()
|
||||
WHERE task.id = $1::uuid`, taskID, string(pricingSnapshotJSON), requestFingerprint)
|
||||
return err
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@ -300,106 +265,6 @@ WHERE task.id = $1::uuid`, taskID, string(pricingSnapshotJSON), requestFingerpri
|
||||
return reservations, err
|
||||
}
|
||||
|
||||
func (s *Store) reserveTaskBillingExact(ctx context.Context, task GatewayTask, gatewayUserID string, amount string, pricingSnapshot map[string]any) ([]WalletBillingReservation, error) {
|
||||
taskID := strings.TrimSpace(task.ID)
|
||||
currency := normalizeWalletCurrency(walletString(pricingSnapshot["currency"]))
|
||||
if currency != "resource" {
|
||||
return nil, fmt.Errorf("unsupported billing currency %q", currency)
|
||||
}
|
||||
pricingSnapshotJSON, _ := json.Marshal(pricingSnapshot)
|
||||
requestFingerprint := walletString(pricingSnapshot["requestFingerprint"])
|
||||
var reservations []WalletBillingReservation
|
||||
err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
|
||||
var positive bool
|
||||
if err := tx.QueryRow(ctx, `SELECT $1::numeric(38, 9) > 0`, amount).Scan(&positive); err != nil {
|
||||
return fmt.Errorf("invalid exact reservation amount: %w", err)
|
||||
}
|
||||
if !positive {
|
||||
return nil
|
||||
}
|
||||
account, err := s.ensureWalletAccount(ctx, tx, gatewayUserID, currency)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
locked, err := lockWalletAccount(ctx, tx, account.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
activeKey, activeAmount, err := activeWalletReservationExact(ctx, tx, locked.ID, taskID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if activeAmount != "" {
|
||||
presentationAmount, _ := strconv.ParseFloat(activeAmount, 64)
|
||||
reservations = []WalletBillingReservation{{
|
||||
TaskID: taskID, AccountID: locked.ID, GatewayUserID: gatewayUserID,
|
||||
GatewayTenantID: firstNonEmpty(locked.GatewayTenantID, task.GatewayTenantID),
|
||||
Currency: locked.Currency, Amount: presentationAmount, IdempotencyKey: activeKey,
|
||||
}}
|
||||
return nil
|
||||
}
|
||||
sequence, err := nextWalletReservationSequence(ctx, tx, locked.ID, taskID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
key := billingReservationIdempotencyKey(taskID, locked.Currency, sequence)
|
||||
var balanceBefore string
|
||||
var frozenBefore string
|
||||
if err := tx.QueryRow(ctx, `
|
||||
SELECT balance::text, frozen_balance::text
|
||||
FROM gateway_wallet_accounts
|
||||
WHERE id = $1::uuid`, locked.ID).Scan(&balanceBefore, &frozenBefore); err != nil {
|
||||
return err
|
||||
}
|
||||
var frozenAfter string
|
||||
err = tx.QueryRow(ctx, `
|
||||
UPDATE gateway_wallet_accounts
|
||||
SET frozen_balance = frozen_balance + $2::numeric(38, 9), updated_at = now()
|
||||
WHERE id = $1::uuid
|
||||
AND balance - frozen_balance >= $2::numeric(38, 9)
|
||||
RETURNING frozen_balance::text`, locked.ID, amount).Scan(&frozenAfter)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return ErrInsufficientWalletBalance
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
metadata, _ := json.Marshal(map[string]any{
|
||||
"taskId": taskID, "reserved": amount, "balance": balanceBefore,
|
||||
"frozenBefore": frozenBefore, "frozenAfter": frozenAfter,
|
||||
"pricingVersion": walletString(pricingSnapshot["pricingVersion"]),
|
||||
})
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO gateway_wallet_transactions (
|
||||
account_id, gateway_tenant_id, gateway_user_id, direction, transaction_type,
|
||||
amount, balance_before, balance_after, idempotency_key, reference_type, reference_id, metadata
|
||||
)
|
||||
VALUES (
|
||||
$1::uuid, NULLIF($2, '')::uuid, $3::uuid, 'debit', 'reserve',
|
||||
$4::numeric(38, 9), $5::numeric, $5::numeric, $6, 'gateway_task', $7, $8::jsonb
|
||||
)`, locked.ID, firstNonEmpty(locked.GatewayTenantID, task.GatewayTenantID), gatewayUserID,
|
||||
amount, balanceBefore, key, taskID, string(metadata)); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE gateway_tasks
|
||||
SET reservation_amount = $2::numeric(38, 9), billing_status = 'pending',
|
||||
billing_currency = $3, pricing_snapshot = $4::jsonb,
|
||||
request_fingerprint = NULLIF($5, ''), billing_updated_at = now(), updated_at = now()
|
||||
WHERE id = $1::uuid`, taskID, amount, currency, string(pricingSnapshotJSON), requestFingerprint); err != nil {
|
||||
return err
|
||||
}
|
||||
presentationAmount, _ := strconv.ParseFloat(amount, 64)
|
||||
reservations = []WalletBillingReservation{{
|
||||
TaskID: taskID, AccountID: locked.ID, GatewayUserID: gatewayUserID,
|
||||
GatewayTenantID: firstNonEmpty(locked.GatewayTenantID, task.GatewayTenantID),
|
||||
Currency: locked.Currency, Amount: presentationAmount, IdempotencyKey: key,
|
||||
}}
|
||||
return nil
|
||||
})
|
||||
return reservations, err
|
||||
}
|
||||
|
||||
func (s *Store) ReleaseTaskBillingReservations(ctx context.Context, reservations []WalletBillingReservation, reason string) error {
|
||||
if len(reservations) == 0 {
|
||||
return nil
|
||||
@ -409,14 +274,10 @@ func (s *Store) ReleaseTaskBillingReservations(ctx context.Context, reservations
|
||||
reason = "task_not_settled"
|
||||
}
|
||||
return pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
|
||||
taskIDs := map[string]struct{}{}
|
||||
for _, reservation := range reservations {
|
||||
if strings.TrimSpace(reservation.AccountID) == "" {
|
||||
if reservation.Amount <= 0 || strings.TrimSpace(reservation.AccountID) == "" {
|
||||
continue
|
||||
}
|
||||
if taskID := strings.TrimSpace(reservation.TaskID); taskID != "" {
|
||||
taskIDs[taskID] = struct{}{}
|
||||
}
|
||||
reserveKey := strings.TrimSpace(reservation.IdempotencyKey)
|
||||
if reserveKey == "" {
|
||||
reserveKey = billingReservationIdempotencyKey(reservation.TaskID, reservation.Currency, 1)
|
||||
@ -442,44 +303,39 @@ SELECT EXISTS (
|
||||
if alreadyReleased {
|
||||
continue
|
||||
}
|
||||
var storedReservedAmount string
|
||||
var storedReservedAmount float64
|
||||
if err := tx.QueryRow(ctx, `
|
||||
SELECT COALESCE((
|
||||
SELECT amount::text
|
||||
SELECT amount::float8
|
||||
FROM gateway_wallet_transactions
|
||||
WHERE account_id = $1::uuid
|
||||
AND idempotency_key = $2
|
||||
AND transaction_type = 'reserve'
|
||||
LIMIT 1
|
||||
), '0')`, reservation.AccountID, reserveKey).Scan(&storedReservedAmount); err != nil {
|
||||
), 0)::float8`, reservation.AccountID, reserveKey).Scan(&storedReservedAmount); err != nil {
|
||||
return err
|
||||
}
|
||||
var positive bool
|
||||
if err := tx.QueryRow(ctx, `SELECT $1::numeric > 0`, storedReservedAmount).Scan(&positive); err != nil {
|
||||
return err
|
||||
}
|
||||
if !positive {
|
||||
if storedReservedAmount <= 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
var balanceBefore string
|
||||
var frozenBefore string
|
||||
var frozenAfter string
|
||||
if err := tx.QueryRow(ctx, `
|
||||
amount := roundMoney(storedReservedAmount)
|
||||
frozenAfter := roundMoney(locked.FrozenBalance - amount)
|
||||
if frozenAfter < 0 {
|
||||
frozenAfter = 0
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE gateway_wallet_accounts
|
||||
SET frozen_balance = frozen_balance - $2::numeric,
|
||||
SET frozen_balance = $2,
|
||||
updated_at = now()
|
||||
WHERE id = $1::uuid
|
||||
AND frozen_balance >= $2::numeric
|
||||
RETURNING balance::text, (frozen_balance + $2::numeric)::text, frozen_balance::text`,
|
||||
locked.ID, storedReservedAmount).Scan(&balanceBefore, &frozenBefore, &frozenAfter); err != nil {
|
||||
WHERE id = $1::uuid`, locked.ID, frozenAfter); err != nil {
|
||||
return err
|
||||
}
|
||||
metadata, _ := json.Marshal(map[string]any{
|
||||
"taskId": reservation.TaskID,
|
||||
"reason": reason,
|
||||
"reserved": storedReservedAmount,
|
||||
"frozenBefore": frozenBefore,
|
||||
"reserved": amount,
|
||||
"frozenBefore": roundMoney(locked.FrozenBalance),
|
||||
"frozenAfter": frozenAfter,
|
||||
})
|
||||
if _, err := tx.Exec(ctx, `
|
||||
@ -489,14 +345,15 @@ INSERT INTO gateway_wallet_transactions (
|
||||
)
|
||||
VALUES (
|
||||
$1::uuid, NULLIF($2, '')::uuid, $3::uuid, 'credit', 'release',
|
||||
$4::numeric, $5::numeric, $5::numeric, $6, 'gateway_task', $7, $8::jsonb
|
||||
$4, $5, $6, $7, 'gateway_task', $8, $9::jsonb
|
||||
)
|
||||
ON CONFLICT (account_id, idempotency_key) WHERE idempotency_key IS NOT NULL DO NOTHING`,
|
||||
locked.ID,
|
||||
locked.GatewayTenantID,
|
||||
locked.GatewayUserID,
|
||||
storedReservedAmount,
|
||||
balanceBefore,
|
||||
amount,
|
||||
roundMoney(locked.Balance),
|
||||
roundMoney(locked.Balance),
|
||||
releaseKey,
|
||||
reservation.TaskID,
|
||||
string(metadata),
|
||||
@ -504,33 +361,6 @@ ON CONFLICT (account_id, idempotency_key) WHERE idempotency_key IS NOT NULL DO N
|
||||
return err
|
||||
}
|
||||
}
|
||||
for taskID := range taskIDs {
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE gateway_tasks task
|
||||
SET reservation_amount = COALESCE((
|
||||
SELECT SUM(reserve.amount)
|
||||
FROM gateway_wallet_transactions reserve
|
||||
WHERE reserve.reference_type = 'gateway_task'
|
||||
AND reserve.reference_id = task.id::text
|
||||
AND reserve.transaction_type = 'reserve'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM gateway_wallet_transactions release
|
||||
WHERE release.account_id = reserve.account_id
|
||||
AND release.transaction_type = 'release'
|
||||
AND release.idempotency_key = reserve.idempotency_key || ':release'
|
||||
)
|
||||
), 0),
|
||||
billing_status = CASE
|
||||
WHEN status IN ('failed', 'cancelled') THEN 'released'
|
||||
WHEN status = 'succeeded' THEN billing_status
|
||||
ELSE 'not_started'
|
||||
END,
|
||||
billing_updated_at = now(),
|
||||
updated_at = now()
|
||||
WHERE id = $1::uuid`, taskID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
@ -758,20 +588,8 @@ func (s *Store) SetUserWalletBalanceTx(ctx context.Context, tx Tx, input WalletB
|
||||
if input.GatewayUserID == "" {
|
||||
return WalletAdjustmentResult{}, ErrLocalUserRequired
|
||||
}
|
||||
targetInput := strings.TrimSpace(input.BalanceText)
|
||||
if targetInput == "" {
|
||||
targetInput = strconv.FormatFloat(input.Balance, 'f', 9, 64)
|
||||
}
|
||||
targetBalance, err := canonicalWalletAmount(ctx, tx, targetInput)
|
||||
if err != nil {
|
||||
return WalletAdjustmentResult{}, err
|
||||
}
|
||||
var nonnegative bool
|
||||
if err := tx.QueryRow(ctx, `SELECT $1::numeric >= 0`, targetBalance).Scan(&nonnegative); err != nil {
|
||||
return WalletAdjustmentResult{}, err
|
||||
}
|
||||
if !nonnegative {
|
||||
return WalletAdjustmentResult{}, ErrInvalidWalletAmount
|
||||
if input.Balance < 0 {
|
||||
return WalletAdjustmentResult{}, fmt.Errorf("wallet balance cannot be negative")
|
||||
}
|
||||
account, err := s.ensureWalletAccount(ctx, tx, input.GatewayUserID, input.Currency)
|
||||
if err != nil {
|
||||
@ -790,68 +608,38 @@ FOR UPDATE`, account.ID))
|
||||
return WalletAdjustmentResult{}, err
|
||||
}
|
||||
before := locked
|
||||
var balanceBefore string
|
||||
var frozenBalance string
|
||||
if err := tx.QueryRow(ctx, `
|
||||
SELECT balance::text, frozen_balance::text
|
||||
FROM gateway_wallet_accounts
|
||||
WHERE id = $1::uuid`, locked.ID).Scan(&balanceBefore, &frozenBalance); err != nil {
|
||||
return WalletAdjustmentResult{}, err
|
||||
}
|
||||
var unchanged bool
|
||||
var belowFrozen bool
|
||||
if err := tx.QueryRow(ctx, `
|
||||
SELECT $1::numeric = $2::numeric, $1::numeric < $3::numeric`,
|
||||
targetBalance, balanceBefore, frozenBalance).Scan(&unchanged, &belowFrozen); err != nil {
|
||||
return WalletAdjustmentResult{}, err
|
||||
}
|
||||
if unchanged {
|
||||
nextBalance := roundMoney(input.Balance)
|
||||
delta := roundMoney(nextBalance - locked.Balance)
|
||||
if delta == 0 {
|
||||
return WalletAdjustmentResult{}, ErrWalletBalanceUnchanged
|
||||
}
|
||||
if belowFrozen {
|
||||
return WalletAdjustmentResult{}, ErrBalanceBelowFrozen
|
||||
}
|
||||
direction := "credit"
|
||||
if strings.HasPrefix(balanceBefore, "-") {
|
||||
return WalletAdjustmentResult{}, ErrInvalidWalletAmount
|
||||
}
|
||||
var targetGreater bool
|
||||
if err := tx.QueryRow(ctx, `SELECT $1::numeric > $2::numeric`, targetBalance, balanceBefore).Scan(&targetGreater); err != nil {
|
||||
return WalletAdjustmentResult{}, err
|
||||
}
|
||||
if !targetGreater {
|
||||
amount := delta
|
||||
if delta < 0 {
|
||||
direction = "debit"
|
||||
}
|
||||
var amount string
|
||||
if err := tx.QueryRow(ctx, `SELECT abs($1::numeric - $2::numeric)::text`, targetBalance, balanceBefore).Scan(&amount); err != nil {
|
||||
return WalletAdjustmentResult{}, err
|
||||
amount = -delta
|
||||
}
|
||||
reason := strings.TrimSpace(input.Reason)
|
||||
if reason == "" {
|
||||
reason = "后台余额调整"
|
||||
}
|
||||
tag, err := tx.Exec(ctx, `
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE gateway_wallet_accounts
|
||||
SET balance = $2::numeric,
|
||||
total_recharged = total_recharged + CASE WHEN $3 = 'credit' THEN $4::numeric ELSE 0 END,
|
||||
SET balance = $2,
|
||||
total_recharged = total_recharged + CASE WHEN $3 = 'credit' THEN $4 ELSE 0 END,
|
||||
updated_at = now()
|
||||
WHERE id = $1::uuid
|
||||
AND $2::numeric >= frozen_balance`,
|
||||
WHERE id = $1::uuid`,
|
||||
locked.ID,
|
||||
targetBalance,
|
||||
nextBalance,
|
||||
direction,
|
||||
amount,
|
||||
)
|
||||
if err != nil {
|
||||
); err != nil {
|
||||
return WalletAdjustmentResult{}, err
|
||||
}
|
||||
if tag.RowsAffected() != 1 {
|
||||
return WalletAdjustmentResult{}, ErrBalanceBelowFrozen
|
||||
}
|
||||
metadata := mergeObjects(input.Metadata, map[string]any{
|
||||
"reason": reason,
|
||||
"previousBalance": balanceBefore,
|
||||
"targetBalance": targetBalance,
|
||||
"previousBalance": roundMoney(before.Balance),
|
||||
"targetBalance": nextBalance,
|
||||
})
|
||||
metadataJSON, _ := json.Marshal(emptyObjectIfNil(metadata))
|
||||
transaction, err := scanWalletTransaction(tx.QueryRow(ctx, `
|
||||
@ -861,7 +649,7 @@ INSERT INTO gateway_wallet_transactions (
|
||||
)
|
||||
VALUES (
|
||||
$1::uuid, NULLIF($2, '')::uuid, $3::uuid, $4, 'admin_adjust',
|
||||
$5::numeric, $6::numeric, $7::numeric, NULLIF($8, ''), 'gateway_user', $9, $10::jsonb
|
||||
$5, $6, $7, NULLIF($8, ''), 'gateway_user', $9, $10::jsonb
|
||||
)
|
||||
RETURNING id::text, account_id::text, COALESCE(gateway_tenant_id::text, ''), COALESCE(gateway_user_id::text, ''),
|
||||
direction, transaction_type, amount::float8, balance_before::float8, balance_after::float8,
|
||||
@ -872,8 +660,8 @@ RETURNING id::text, account_id::text, COALESCE(gateway_tenant_id::text, ''), COA
|
||||
locked.GatewayUserID,
|
||||
direction,
|
||||
amount,
|
||||
balanceBefore,
|
||||
targetBalance,
|
||||
roundMoney(before.Balance),
|
||||
nextBalance,
|
||||
strings.TrimSpace(input.IdempotencyKey),
|
||||
locked.GatewayUserID,
|
||||
string(metadataJSON),
|
||||
@ -881,10 +669,11 @@ RETURNING id::text, account_id::text, COALESCE(gateway_tenant_id::text, ''), COA
|
||||
if err != nil {
|
||||
return WalletAdjustmentResult{}, err
|
||||
}
|
||||
locked, err = s.ensureWalletAccount(ctx, tx, input.GatewayUserID, input.Currency)
|
||||
if err != nil {
|
||||
return WalletAdjustmentResult{}, err
|
||||
locked.Balance = nextBalance
|
||||
if direction == "credit" {
|
||||
locked.TotalRecharged = roundMoney(locked.TotalRecharged + amount)
|
||||
}
|
||||
locked.UpdatedAt = time.Now()
|
||||
return WalletAdjustmentResult{Account: locked, Before: before, Transaction: transaction}, nil
|
||||
}
|
||||
|
||||
@ -906,20 +695,9 @@ func (s *Store) RechargeUserWalletBalanceTx(ctx context.Context, tx Tx, input Wa
|
||||
if input.GatewayUserID == "" {
|
||||
return WalletAdjustmentResult{}, ErrLocalUserRequired
|
||||
}
|
||||
amountInput := strings.TrimSpace(input.AmountText)
|
||||
if amountInput == "" {
|
||||
amountInput = strconv.FormatFloat(input.Amount, 'f', 9, 64)
|
||||
}
|
||||
amount, err := canonicalWalletAmount(ctx, tx, amountInput)
|
||||
if err != nil {
|
||||
return WalletAdjustmentResult{}, err
|
||||
}
|
||||
var positive bool
|
||||
if err := tx.QueryRow(ctx, `SELECT $1::numeric > 0`, amount).Scan(&positive); err != nil {
|
||||
return WalletAdjustmentResult{}, err
|
||||
}
|
||||
if !positive {
|
||||
return WalletAdjustmentResult{}, ErrInvalidWalletAmount
|
||||
amount := roundMoney(input.Amount)
|
||||
if amount <= 0 {
|
||||
return WalletAdjustmentResult{}, fmt.Errorf("wallet recharge amount must be positive")
|
||||
}
|
||||
account, err := s.ensureWalletAccount(ctx, tx, input.GatewayUserID, input.Currency)
|
||||
if err != nil {
|
||||
@ -937,30 +715,26 @@ FOR UPDATE`, account.ID))
|
||||
return WalletAdjustmentResult{}, err
|
||||
}
|
||||
before := locked
|
||||
var balanceBefore string
|
||||
if err := tx.QueryRow(ctx, `SELECT balance::text FROM gateway_wallet_accounts WHERE id = $1::uuid`, locked.ID).Scan(&balanceBefore); err != nil {
|
||||
return WalletAdjustmentResult{}, err
|
||||
}
|
||||
nextBalance := roundMoney(locked.Balance + amount)
|
||||
reason := strings.TrimSpace(input.Reason)
|
||||
if reason == "" {
|
||||
reason = "后台余额充值"
|
||||
}
|
||||
var nextBalance string
|
||||
if err := tx.QueryRow(ctx, `
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE gateway_wallet_accounts
|
||||
SET balance = balance + $2::numeric,
|
||||
total_recharged = total_recharged + $2::numeric,
|
||||
SET balance = $2,
|
||||
total_recharged = total_recharged + $3,
|
||||
updated_at = now()
|
||||
WHERE id = $1::uuid
|
||||
RETURNING balance::text`,
|
||||
WHERE id = $1::uuid`,
|
||||
locked.ID,
|
||||
nextBalance,
|
||||
amount,
|
||||
).Scan(&nextBalance); err != nil {
|
||||
); err != nil {
|
||||
return WalletAdjustmentResult{}, err
|
||||
}
|
||||
metadata := mergeObjects(input.Metadata, map[string]any{
|
||||
"reason": reason,
|
||||
"previousBalance": balanceBefore,
|
||||
"previousBalance": roundMoney(before.Balance),
|
||||
"rechargeAmount": amount,
|
||||
"targetBalance": nextBalance,
|
||||
})
|
||||
@ -972,7 +746,7 @@ INSERT INTO gateway_wallet_transactions (
|
||||
)
|
||||
VALUES (
|
||||
$1::uuid, NULLIF($2, '')::uuid, $3::uuid, 'credit', 'recharge',
|
||||
$4::numeric, $5::numeric, $6::numeric, NULLIF($7, ''), 'gateway_user', $8, $9::jsonb
|
||||
$4, $5, $6, NULLIF($7, ''), 'gateway_user', $8, $9::jsonb
|
||||
)
|
||||
RETURNING id::text, account_id::text, COALESCE(gateway_tenant_id::text, ''), COALESCE(gateway_user_id::text, ''),
|
||||
direction, transaction_type, amount::float8, balance_before::float8, balance_after::float8,
|
||||
@ -982,7 +756,7 @@ RETURNING id::text, account_id::text, COALESCE(gateway_tenant_id::text, ''), COA
|
||||
locked.GatewayTenantID,
|
||||
locked.GatewayUserID,
|
||||
amount,
|
||||
balanceBefore,
|
||||
roundMoney(before.Balance),
|
||||
nextBalance,
|
||||
strings.TrimSpace(input.IdempotencyKey),
|
||||
locked.GatewayUserID,
|
||||
@ -991,21 +765,12 @@ RETURNING id::text, account_id::text, COALESCE(gateway_tenant_id::text, ''), COA
|
||||
if err != nil {
|
||||
return WalletAdjustmentResult{}, err
|
||||
}
|
||||
locked, err = s.ensureWalletAccount(ctx, tx, input.GatewayUserID, input.Currency)
|
||||
if err != nil {
|
||||
return WalletAdjustmentResult{}, err
|
||||
}
|
||||
locked.Balance = nextBalance
|
||||
locked.TotalRecharged = roundMoney(locked.TotalRecharged + amount)
|
||||
locked.UpdatedAt = time.Now()
|
||||
return WalletAdjustmentResult{Account: locked, Before: before, Transaction: transaction}, nil
|
||||
}
|
||||
|
||||
func canonicalWalletAmount(ctx context.Context, q Tx, value string) (string, error) {
|
||||
var canonical string
|
||||
if err := q.QueryRow(ctx, `SELECT $1::numeric(38, 9)::text`, strings.TrimSpace(value)).Scan(&canonical); err != nil {
|
||||
return "", fmt.Errorf("%w: %v", ErrInvalidWalletAmount, err)
|
||||
}
|
||||
return canonical, nil
|
||||
}
|
||||
|
||||
func (s *Store) ensureWalletAccount(ctx context.Context, q Tx, gatewayUserID string, currency string) (GatewayWalletAccount, error) {
|
||||
currency = normalizeWalletCurrency(currency)
|
||||
if _, err := q.Exec(ctx, `
|
||||
@ -1029,9 +794,6 @@ WHERE gateway_wallet_accounts.gateway_tenant_id IS NULL
|
||||
OR COALESCE(gateway_wallet_accounts.user_id, '') = ''`, gatewayUserID, currency); err != nil {
|
||||
return GatewayWalletAccount{}, err
|
||||
}
|
||||
if err := ensureWalletAccountAuditGuard(ctx, q, gatewayUserID, currency); err != nil {
|
||||
return GatewayWalletAccount{}, err
|
||||
}
|
||||
account, err := scanWalletAccount(q.QueryRow(ctx, `
|
||||
SELECT id::text, COALESCE(gateway_tenant_id::text, ''), gateway_user_id::text,
|
||||
COALESCE(tenant_id, ''), COALESCE(tenant_key, ''), COALESCE(user_id, ''),
|
||||
@ -1049,17 +811,6 @@ WHERE gateway_user_id = $1::uuid
|
||||
return account, nil
|
||||
}
|
||||
|
||||
func ensureWalletAccountAuditGuard(ctx context.Context, q Tx, gatewayUserID string, currency string) error {
|
||||
_, err := q.Exec(ctx, `
|
||||
INSERT INTO gateway_wallet_account_audit_guards (account_id)
|
||||
SELECT id
|
||||
FROM gateway_wallet_accounts
|
||||
WHERE gateway_user_id = $1::uuid
|
||||
AND currency = $2
|
||||
ON CONFLICT (account_id) DO NOTHING`, gatewayUserID, normalizeWalletCurrency(currency))
|
||||
return err
|
||||
}
|
||||
|
||||
func lockWalletAccount(ctx context.Context, tx pgx.Tx, accountID string) (GatewayWalletAccount, error) {
|
||||
return scanWalletAccount(tx.QueryRow(ctx, `
|
||||
SELECT id::text, COALESCE(gateway_tenant_id::text, ''), gateway_user_id::text,
|
||||
@ -1100,31 +851,6 @@ LIMIT 1`, accountID, taskID).Scan(&key, &amount)
|
||||
return key, roundMoney(amount), nil
|
||||
}
|
||||
|
||||
func activeWalletReservationExact(ctx context.Context, tx pgx.Tx, accountID string, taskID string) (string, string, error) {
|
||||
var key string
|
||||
var amount string
|
||||
err := tx.QueryRow(ctx, `
|
||||
SELECT COALESCE(t.idempotency_key, ''), t.amount::text
|
||||
FROM gateway_wallet_transactions t
|
||||
WHERE t.account_id = $1::uuid
|
||||
AND t.reference_type = 'gateway_task'
|
||||
AND t.reference_id = $2
|
||||
AND t.transaction_type = 'reserve'
|
||||
AND COALESCE(t.idempotency_key, '') <> ''
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM gateway_wallet_transactions release
|
||||
WHERE release.account_id = t.account_id
|
||||
AND release.transaction_type = 'release'
|
||||
AND release.idempotency_key = t.idempotency_key || ':release'
|
||||
)
|
||||
ORDER BY t.created_at DESC
|
||||
LIMIT 1`, accountID, taskID).Scan(&key, &amount)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return "", "", nil
|
||||
}
|
||||
return key, amount, err
|
||||
}
|
||||
|
||||
func nextWalletReservationSequence(ctx context.Context, tx pgx.Tx, accountID string, taskID string) (int, error) {
|
||||
var count int
|
||||
if err := tx.QueryRow(ctx, `
|
||||
|
||||
@ -132,10 +132,6 @@ RETURNING id::text`, "wallet-reservation-user-"+suffix, "wallet_reservation_"+su
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
cleanupCtx := context.Background()
|
||||
_, _ = db.pool.Exec(cleanupCtx, `DELETE FROM gateway_wallet_transactions WHERE gateway_user_id = $1::uuid`, userID)
|
||||
_, _ = db.pool.Exec(cleanupCtx, `
|
||||
DELETE FROM gateway_wallet_account_audit_guards
|
||||
WHERE account_id IN (SELECT id FROM gateway_wallet_accounts WHERE gateway_user_id = $1::uuid)`, userID)
|
||||
_, _ = db.pool.Exec(cleanupCtx, `DELETE FROM gateway_users WHERE id = $1::uuid`, userID)
|
||||
_, _ = db.pool.Exec(cleanupCtx, `DELETE FROM gateway_tenants WHERE id = $1::uuid`, tenantID)
|
||||
})
|
||||
|
||||
@ -1,234 +0,0 @@
|
||||
ALTER TABLE gateway_tasks
|
||||
ADD COLUMN IF NOT EXISTS billing_version text DEFAULT 'effective-pricing-v2',
|
||||
ADD COLUMN IF NOT EXISTS billing_status text DEFAULT 'not_started',
|
||||
ADD COLUMN IF NOT EXISTS billing_currency text DEFAULT 'resource',
|
||||
ADD COLUMN IF NOT EXISTS pricing_snapshot jsonb DEFAULT '{}'::jsonb,
|
||||
ADD COLUMN IF NOT EXISTS request_fingerprint text,
|
||||
ADD COLUMN IF NOT EXISTS idempotency_key_hash text,
|
||||
ADD COLUMN IF NOT EXISTS idempotency_request_hash text,
|
||||
ADD COLUMN IF NOT EXISTS reservation_amount numeric(38, 9) DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS execution_token uuid,
|
||||
ADD COLUMN IF NOT EXISTS execution_lease_expires_at timestamptz,
|
||||
ADD COLUMN IF NOT EXISTS billing_updated_at timestamptz,
|
||||
ADD COLUMN IF NOT EXISTS billing_settled_at timestamptz;
|
||||
|
||||
UPDATE gateway_tasks
|
||||
SET billing_version = COALESCE(billing_version, 'effective-pricing-v2'),
|
||||
billing_status = COALESCE(billing_status, 'not_started'),
|
||||
billing_currency = COALESCE(billing_currency, 'resource'),
|
||||
pricing_snapshot = COALESCE(pricing_snapshot, '{}'::jsonb),
|
||||
reservation_amount = COALESCE(reservation_amount, 0);
|
||||
|
||||
ALTER TABLE gateway_tasks
|
||||
ADD CONSTRAINT gateway_tasks_billing_status_v2_check CHECK (
|
||||
billing_status IN (
|
||||
'not_started', 'pending', 'processing', 'settled', 'released',
|
||||
'retryable_failed', 'manual_review', 'not_required'
|
||||
)
|
||||
) NOT VALID,
|
||||
ADD CONSTRAINT gateway_tasks_reservation_amount_v2_check CHECK (reservation_amount >= 0) NOT VALID;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uniq_gateway_tasks_idempotency_hash_v2
|
||||
ON gateway_tasks(user_id, idempotency_key_hash)
|
||||
WHERE idempotency_key_hash IS NOT NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_gateway_tasks_execution_lease_v2
|
||||
ON gateway_tasks(status, execution_lease_expires_at)
|
||||
WHERE status = 'running';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_gateway_tasks_billing_status_v2
|
||||
ON gateway_tasks(billing_status, billing_updated_at, created_at);
|
||||
|
||||
ALTER TABLE gateway_task_attempts
|
||||
ADD COLUMN IF NOT EXISTS pricing_snapshot jsonb DEFAULT '{}'::jsonb,
|
||||
ADD COLUMN IF NOT EXISTS request_fingerprint text,
|
||||
ADD COLUMN IF NOT EXISTS upstream_submission_status text DEFAULT 'not_submitted',
|
||||
ADD COLUMN IF NOT EXISTS upstream_submission_updated_at timestamptz;
|
||||
|
||||
UPDATE gateway_task_attempts
|
||||
SET pricing_snapshot = COALESCE(pricing_snapshot, '{}'::jsonb),
|
||||
upstream_submission_status = COALESCE(upstream_submission_status, 'not_submitted');
|
||||
|
||||
ALTER TABLE gateway_task_attempts
|
||||
ADD CONSTRAINT gateway_task_attempts_submission_v2_check CHECK (
|
||||
upstream_submission_status IN ('not_submitted', 'submitting', 'response_received')
|
||||
) NOT VALID;
|
||||
|
||||
ALTER TABLE settlement_outbox
|
||||
ADD COLUMN IF NOT EXISTS action text DEFAULT 'settle',
|
||||
ADD COLUMN IF NOT EXISTS amount numeric(38, 9) DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS currency text DEFAULT 'resource',
|
||||
ADD COLUMN IF NOT EXISTS pricing_snapshot jsonb DEFAULT '{}'::jsonb,
|
||||
ADD COLUMN IF NOT EXISTS locked_by text,
|
||||
ADD COLUMN IF NOT EXISTS lock_token uuid,
|
||||
ADD COLUMN IF NOT EXISTS locked_at timestamptz,
|
||||
ADD COLUMN IF NOT EXISTS last_error_code text,
|
||||
ADD COLUMN IF NOT EXISTS last_error_message text,
|
||||
ADD COLUMN IF NOT EXISTS completed_at timestamptz,
|
||||
ADD COLUMN IF NOT EXISTS manual_review_reason text,
|
||||
ADD COLUMN IF NOT EXISTS retry_idempotency_key_hash text,
|
||||
ADD COLUMN IF NOT EXISTS retry_requested_at timestamptz;
|
||||
|
||||
UPDATE settlement_outbox
|
||||
SET action = COALESCE(action, 'settle'),
|
||||
amount = COALESCE(amount, 0),
|
||||
currency = COALESCE(currency, 'resource'),
|
||||
pricing_snapshot = COALESCE(pricing_snapshot, '{}'::jsonb);
|
||||
|
||||
ALTER TABLE settlement_outbox
|
||||
ADD CONSTRAINT settlement_outbox_action_v2_check CHECK (action IN ('settle', 'release')) NOT VALID,
|
||||
ADD CONSTRAINT settlement_outbox_status_v2_check CHECK (
|
||||
status IN ('pending', 'processing', 'retryable_failed', 'completed', 'manual_review')
|
||||
) NOT VALID,
|
||||
ADD CONSTRAINT settlement_outbox_amount_v2_check CHECK (amount >= 0) NOT VALID;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_settlement_outbox_claim_v2
|
||||
ON settlement_outbox(status, next_attempt_at, created_at)
|
||||
WHERE status IN ('pending', 'retryable_failed');
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_settlement_outbox_stale_v2
|
||||
ON settlement_outbox(status, locked_at)
|
||||
WHERE status = 'processing';
|
||||
|
||||
ALTER TABLE model_pricing_rules
|
||||
ADD COLUMN IF NOT EXISTS is_free boolean DEFAULT false;
|
||||
|
||||
UPDATE model_pricing_rules SET is_free = false WHERE is_free IS NULL;
|
||||
|
||||
UPDATE model_pricing_rules
|
||||
SET calculator_type = CASE
|
||||
WHEN resource_type IN ('text_input', 'text_cached_input', 'text_output', 'text_total') THEN 'token_usage'
|
||||
WHEN resource_type = 'video' THEN 'duration_weight'
|
||||
ELSE calculator_type
|
||||
END,
|
||||
unit = CASE
|
||||
WHEN resource_type = 'video' AND unit = 'video' THEN '5s'
|
||||
ELSE unit
|
||||
END,
|
||||
updated_at = now()
|
||||
WHERE (resource_type IN ('text_input', 'text_cached_input', 'text_output', 'text_total') AND calculator_type = 'unit_weight')
|
||||
OR (resource_type = 'video' AND (calculator_type = 'unit_weight' OR unit = 'video'));
|
||||
|
||||
ALTER TABLE model_pricing_rules
|
||||
ADD CONSTRAINT model_pricing_rules_explicit_free_v2_check CHECK (
|
||||
base_price > 0 OR (base_price = 0 AND is_free)
|
||||
) NOT VALID;
|
||||
|
||||
ALTER TABLE gateway_wallet_accounts
|
||||
ADD CONSTRAINT gateway_wallet_nonnegative_frozen_v2_check CHECK (frozen_balance >= 0) NOT VALID,
|
||||
ADD CONSTRAINT gateway_wallet_balance_covers_frozen_v2_check CHECK (balance >= frozen_balance) NOT VALID;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS gateway_wallet_account_audit_guards (
|
||||
account_id uuid PRIMARY KEY REFERENCES gateway_wallet_accounts(id) ON DELETE RESTRICT,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
INSERT INTO gateway_wallet_account_audit_guards (account_id)
|
||||
SELECT id FROM gateway_wallet_accounts
|
||||
ON CONFLICT (account_id) DO NOTHING;
|
||||
|
||||
ALTER TABLE gateway_wallet_transactions
|
||||
ADD CONSTRAINT gateway_wallet_transactions_audit_guard_v2
|
||||
FOREIGN KEY (account_id) REFERENCES gateway_wallet_account_audit_guards(account_id) ON DELETE RESTRICT NOT VALID;
|
||||
|
||||
UPDATE gateway_tasks task
|
||||
SET billing_status = 'not_required',
|
||||
billing_updated_at = now()
|
||||
WHERE task.run_mode <> 'production'
|
||||
OR task.gateway_user_id IS NULL;
|
||||
|
||||
UPDATE gateway_tasks task
|
||||
SET billing_status = 'settled',
|
||||
billing_currency = COALESCE(NULLIF(task.billing_summary->>'currency', ''), 'resource'),
|
||||
billing_updated_at = now(),
|
||||
billing_settled_at = COALESCE(task.finished_at, task.updated_at)
|
||||
WHERE task.status = 'succeeded'
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM gateway_wallet_transactions transaction
|
||||
WHERE transaction.reference_type = 'gateway_task'
|
||||
AND transaction.reference_id = task.id::text
|
||||
AND transaction.transaction_type = 'task_billing'
|
||||
);
|
||||
|
||||
UPDATE gateway_tasks task
|
||||
SET billing_status = 'manual_review',
|
||||
billing_currency = COALESCE(NULLIF(task.billing_summary->>'currency', ''), 'resource'),
|
||||
billing_updated_at = now()
|
||||
WHERE task.status = 'succeeded'
|
||||
AND task.run_mode = 'production'
|
||||
AND task.gateway_user_id IS NOT NULL
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM gateway_wallet_transactions transaction
|
||||
WHERE transaction.reference_type = 'gateway_task'
|
||||
AND transaction.reference_id = task.id::text
|
||||
AND transaction.transaction_type = 'task_billing'
|
||||
);
|
||||
|
||||
INSERT INTO settlement_outbox (
|
||||
task_id, event_type, action, amount, currency, pricing_snapshot, payload,
|
||||
status, next_attempt_at, manual_review_reason
|
||||
)
|
||||
SELECT task.id,
|
||||
'task.billing.review',
|
||||
'settle',
|
||||
COALESCE(task.final_charge_amount, 0),
|
||||
COALESCE(NULLIF(task.billing_summary->>'currency', ''), 'resource'),
|
||||
COALESCE(task.pricing_snapshot, '{}'::jsonb),
|
||||
jsonb_build_object(
|
||||
'taskId', task.id,
|
||||
'classification', 'historical_success_without_charge'
|
||||
),
|
||||
'manual_review',
|
||||
now(),
|
||||
'historical_success_without_charge'
|
||||
FROM gateway_tasks task
|
||||
WHERE task.billing_status = 'manual_review'
|
||||
AND task.status = 'succeeded'
|
||||
AND task.run_mode = 'production'
|
||||
AND task.gateway_user_id IS NOT NULL
|
||||
ON CONFLICT (task_id, event_type) DO NOTHING;
|
||||
|
||||
INSERT INTO settlement_outbox (
|
||||
task_id, event_type, action, amount, currency, payload, status, next_attempt_at
|
||||
)
|
||||
SELECT task.id,
|
||||
'task.billing.release',
|
||||
'release',
|
||||
transaction.amount,
|
||||
account.currency,
|
||||
jsonb_build_object(
|
||||
'taskId', task.id,
|
||||
'reservationIdempotencyKey', transaction.idempotency_key,
|
||||
'classification', 'historical_terminal_reservation'
|
||||
),
|
||||
'pending',
|
||||
now()
|
||||
FROM gateway_tasks task
|
||||
JOIN gateway_wallet_transactions transaction
|
||||
ON transaction.reference_type = 'gateway_task'
|
||||
AND transaction.reference_id = task.id::text
|
||||
AND transaction.transaction_type = 'reserve'
|
||||
JOIN gateway_wallet_accounts account ON account.id = transaction.account_id
|
||||
WHERE task.status IN ('failed', 'cancelled')
|
||||
AND COALESCE(transaction.idempotency_key, '') <> ''
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM gateway_wallet_transactions release
|
||||
WHERE release.account_id = transaction.account_id
|
||||
AND release.transaction_type = 'release'
|
||||
AND release.idempotency_key = transaction.idempotency_key || ':release'
|
||||
)
|
||||
ON CONFLICT (task_id, event_type) DO NOTHING;
|
||||
|
||||
UPDATE gateway_tasks task
|
||||
SET billing_status = 'pending',
|
||||
billing_updated_at = now()
|
||||
WHERE EXISTS (
|
||||
SELECT 1
|
||||
FROM settlement_outbox outbox
|
||||
WHERE outbox.task_id = task.id
|
||||
AND outbox.action = 'release'
|
||||
AND outbox.status IN ('pending', 'processing', 'retryable_failed')
|
||||
);
|
||||
@ -3,8 +3,6 @@ import type {
|
||||
AuthUser,
|
||||
BaseModelCatalogItem,
|
||||
BaseModelUpsertRequest,
|
||||
BillingSettlementListResponse,
|
||||
BillingSettlementRetryResponse,
|
||||
CatalogProvider,
|
||||
CatalogProviderUpsertRequest,
|
||||
ClientCustomizationSettings,
|
||||
@ -898,12 +896,10 @@ export async function uploadFileToStorage(
|
||||
export async function estimatePricing(
|
||||
token: string,
|
||||
input: Record<string, unknown>,
|
||||
signal?: AbortSignal,
|
||||
): Promise<GatewayPricingEstimate> {
|
||||
return request<GatewayPricingEstimate>('/api/v1/pricing/estimate', {
|
||||
body: input,
|
||||
method: 'POST',
|
||||
signal,
|
||||
token,
|
||||
});
|
||||
}
|
||||
@ -1002,28 +998,6 @@ export async function restoreModelRuntimeStatus(token: string, platformModelId:
|
||||
});
|
||||
}
|
||||
|
||||
export async function listBillingSettlements(
|
||||
token: string,
|
||||
input: { status?: string; action?: string; page?: number; pageSize?: number; signal?: AbortSignal } = {},
|
||||
): Promise<BillingSettlementListResponse> {
|
||||
const search = new URLSearchParams({
|
||||
page: String(input.page ?? 1),
|
||||
pageSize: String(input.pageSize ?? 50),
|
||||
});
|
||||
if (input.status) search.set('status', input.status);
|
||||
if (input.action) search.set('action', input.action);
|
||||
return request<BillingSettlementListResponse>(`/api/admin/runtime/billing-settlements?${search.toString()}`, { token, signal: input.signal });
|
||||
}
|
||||
|
||||
export async function retryBillingSettlement(token: string, settlementId: string): Promise<BillingSettlementRetryResponse> {
|
||||
const idempotencyKey = crypto.randomUUID();
|
||||
return request<BillingSettlementRetryResponse>(`/api/admin/runtime/billing-settlements/${settlementId}/retry`, {
|
||||
method: 'POST',
|
||||
token,
|
||||
headers: { 'Idempotency-Key': idempotencyKey },
|
||||
});
|
||||
}
|
||||
|
||||
export async function getNetworkProxyConfig(token: string): Promise<GatewayNetworkProxyConfig> {
|
||||
return request<GatewayNetworkProxyConfig>('/api/admin/config/network-proxy', { token });
|
||||
}
|
||||
@ -1206,7 +1180,7 @@ export async function deleteFileStorageChannel(token: string, channelId: string)
|
||||
|
||||
async function request<T>(
|
||||
path: string,
|
||||
options: { token?: string; auth?: boolean; method?: string; body?: unknown; headers?: Record<string, string>; signal?: AbortSignal } = {},
|
||||
options: { token?: string; auth?: boolean; method?: string; body?: unknown; headers?: Record<string, string> } = {},
|
||||
): Promise<T> {
|
||||
const headers: Record<string, string> = { ...(options.headers ?? {}) };
|
||||
if (options.auth !== false && options.token && options.token !== OIDC_BROWSER_SESSION_CREDENTIAL) {
|
||||
@ -1220,7 +1194,6 @@ async function request<T>(
|
||||
headers,
|
||||
body: options.body === undefined ? undefined : JSON.stringify(options.body),
|
||||
credentials: 'include',
|
||||
signal: options.signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
const body = await response.text();
|
||||
|
||||
@ -26,32 +26,6 @@ describe('OIDC BFF navigation', () => {
|
||||
expect(target.searchParams.has('code_challenge')).toBe(false);
|
||||
});
|
||||
|
||||
it('resolves the production relative API base against the browser origin', async () => {
|
||||
vi.stubEnv('VITE_GATEWAY_API_BASE_URL', '/gateway-api');
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
enabled: true,
|
||||
oidcLogin: true,
|
||||
loginUrl: '/api/v1/auth/oidc/login',
|
||||
logoutUrl: '/api/v1/auth/oidc/logout',
|
||||
status: 'active',
|
||||
}),
|
||||
}));
|
||||
const assign = vi.fn();
|
||||
vi.stubGlobal('window', {
|
||||
location: { origin: 'https://ai.51easyai.com', pathname: '/workspace/overview', search: '', hash: '', assign },
|
||||
});
|
||||
|
||||
const { startOIDCLogin } = await import('./oidc');
|
||||
await startOIDCLogin();
|
||||
|
||||
const target = new URL(String(assign.mock.calls[0]?.[0]));
|
||||
expect(target.origin).toBe('https://ai.51easyai.com');
|
||||
expect(target.pathname).toBe('/gateway-api/api/v1/auth/oidc/login');
|
||||
expect(target.searchParams.get('returnTo')).toBe('/workspace/overview');
|
||||
});
|
||||
|
||||
it('submits logout as a top-level POST without exposing tokens', async () => {
|
||||
vi.stubEnv('VITE_GATEWAY_API_BASE_URL', 'https://gateway.example.com/gateway-api');
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||||
|
||||
@ -77,7 +77,7 @@ export async function startOIDCLogin() {
|
||||
const configuration = await loadOIDCRuntimeConfiguration(true);
|
||||
if (!configuration.enabled || !configuration.oidcLogin || !configuration.loginUrl) throw new Error('统一认证未配置');
|
||||
const returnTo = `${window.location.pathname}${window.location.search}${window.location.hash}` || '/';
|
||||
const loginURL = new URL(identityEndpointURL(configuration.loginUrl), window.location.origin);
|
||||
const loginURL = new URL(identityEndpointURL(configuration.loginUrl));
|
||||
loginURL.searchParams.set('returnTo', returnTo.startsWith('/') ? returnTo : '/');
|
||||
window.location.assign(loginURL.toString());
|
||||
}
|
||||
|
||||
@ -26,7 +26,6 @@ import type { AdminSection, LoadState, PlatformWithModelsInput } from '../types'
|
||||
import { AccessRulesPanel } from './admin/AccessRulesPanel';
|
||||
import { AuditLogsPanel } from './admin/AuditLogsPanel';
|
||||
import { BaseModelCatalogPanel } from './admin/BaseModelCatalogPanel';
|
||||
import { BillingSettlementsPanel } from './admin/BillingSettlementsPanel';
|
||||
import { TenantsPanel, UserGroupsPanel, UsersPanel } from './admin/IdentityManagementPanels';
|
||||
import { PlatformManagementPanel } from './admin/PlatformManagementPanel';
|
||||
import { PricingRulesPanel } from './admin/PricingRulesPanel';
|
||||
@ -132,18 +131,15 @@ export function AdminPage(props: {
|
||||
/>
|
||||
)}
|
||||
{props.section === 'runtime' && (
|
||||
<div className="pageStack">
|
||||
<BillingSettlementsPanel token={props.token} />
|
||||
<RuntimePoliciesPanel
|
||||
message={props.operationMessage}
|
||||
runnerPolicy={props.data.runnerPolicy}
|
||||
runtimePolicySets={props.data.runtimePolicySets}
|
||||
state={props.state}
|
||||
onDeleteRuntimePolicySet={props.onDeleteRuntimePolicySet}
|
||||
onSaveRunnerPolicy={props.onSaveRunnerPolicy}
|
||||
onSaveRuntimePolicySet={props.onSaveRuntimePolicySet}
|
||||
/>
|
||||
</div>
|
||||
<RuntimePoliciesPanel
|
||||
message={props.operationMessage}
|
||||
runnerPolicy={props.data.runnerPolicy}
|
||||
runtimePolicySets={props.data.runtimePolicySets}
|
||||
state={props.state}
|
||||
onDeleteRuntimePolicySet={props.onDeleteRuntimePolicySet}
|
||||
onSaveRunnerPolicy={props.onSaveRunnerPolicy}
|
||||
onSaveRuntimePolicySet={props.onSaveRuntimePolicySet}
|
||||
/>
|
||||
)}
|
||||
{props.section === 'accessRules' && (
|
||||
<AccessRulesPanel
|
||||
|
||||
@ -1,57 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { GatewayPricingEstimate } from '@easyai-ai-gateway/contracts';
|
||||
import {
|
||||
MEDIA_ESTIMATE_DEBOUNCE_MS,
|
||||
billingEstimateSignature,
|
||||
mediaEstimateAllowsProduction,
|
||||
mediaEstimateFromResponse,
|
||||
} from './PlaygroundPage';
|
||||
|
||||
describe('playground pricing estimate', () => {
|
||||
it('uses the specified 350ms debounce interval', () => {
|
||||
expect(MEDIA_ESTIMATE_DEBOUNCE_MS).toBe(350);
|
||||
});
|
||||
|
||||
it('only changes its signature for billing-relevant parameters', () => {
|
||||
const first = billingEstimateSignature({
|
||||
kind: 'images.generations', model: 'image-model', prompt: 'first',
|
||||
count: 1, quality: 'high', resolution: '2K',
|
||||
});
|
||||
const promptOnly = billingEstimateSignature({
|
||||
kind: 'images.generations', model: 'image-model', prompt: 'second',
|
||||
count: 1, quality: 'high', resolution: '2K',
|
||||
});
|
||||
const changedCount = billingEstimateSignature({
|
||||
kind: 'images.generations', model: 'image-model', prompt: 'second',
|
||||
count: 2, quality: 'high', resolution: '2K',
|
||||
});
|
||||
expect(promptOnly).toBe(first);
|
||||
expect(changedCount).not.toBe(first);
|
||||
});
|
||||
|
||||
it('distinguishes explicit free and exposes the reservation ceiling', () => {
|
||||
const response: GatewayPricingEstimate = {
|
||||
candidateCount: 2,
|
||||
currency: 'resource',
|
||||
items: [{ amount: 0, currency: 'resource', isFree: true }],
|
||||
pricingVersion: 'effective-pricing-v2',
|
||||
requestFingerprint: 'fingerprint',
|
||||
reservationAmount: 0,
|
||||
resolver: 'effective-pricing-v2',
|
||||
totalAmount: 0,
|
||||
};
|
||||
expect(mediaEstimateFromResponse(response)).toMatchObject({
|
||||
amount: 0,
|
||||
candidateCount: 2,
|
||||
reservationAmount: 0,
|
||||
status: 'free',
|
||||
});
|
||||
});
|
||||
|
||||
it('only allows production submission for the current pricing signature', () => {
|
||||
expect(mediaEstimateAllowsProduction({ status: 'ready', signature: 'current' }, 'current')).toBe(true);
|
||||
expect(mediaEstimateAllowsProduction({ status: 'free', signature: 'current' }, 'current')).toBe(true);
|
||||
expect(mediaEstimateAllowsProduction({ status: 'ready', signature: 'stale' }, 'current')).toBe(false);
|
||||
expect(mediaEstimateAllowsProduction({ status: 'loading', signature: 'current' }, 'current')).toBe(false);
|
||||
});
|
||||
});
|
||||
@ -2,7 +2,7 @@ import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { GatewayApiKey, GatewayPricingEstimate, GatewayTask, PlatformModel } from '@easyai-ai-gateway/contracts';
|
||||
import { ArrowUp, ChevronDown, MessageSquarePlus, Settings2, Sparkles } from 'lucide-react';
|
||||
import { Badge, Button, FormDialog, Select, Textarea } from '../components/ui';
|
||||
import { GatewayApiError, createImageEditTask, createImageGenerationTask, createVideoGenerationTask, estimatePricing, pollTaskUntilSettled, resolveApiAssetUrl, taskIsPending } from '../api';
|
||||
import { createImageEditTask, createImageGenerationTask, createVideoGenerationTask, estimatePricing, pollTaskUntilSettled, resolveApiAssetUrl, taskIsPending } from '../api';
|
||||
import type { PlaygroundMode } from '../types';
|
||||
import {
|
||||
PlaygroundPromptMentionInput,
|
||||
@ -56,18 +56,13 @@ import {
|
||||
|
||||
const MEDIA_RUNS_STORAGE_KEY = 'easyai:playground:media-runs:v1';
|
||||
const MEDIA_RUNS_STORAGE_LIMIT = 50;
|
||||
export const MEDIA_ESTIMATE_DEBOUNCE_MS = 350;
|
||||
|
||||
type MediaEstimateState = {
|
||||
amount?: number;
|
||||
reservationAmount?: number;
|
||||
candidateCount?: number;
|
||||
currency?: string;
|
||||
error?: string;
|
||||
pricingVersion?: string;
|
||||
resolver?: string;
|
||||
signature?: string;
|
||||
status: 'idle' | 'loading' | 'ready' | 'free' | 'unavailable' | 'error';
|
||||
status: 'idle' | 'loading' | 'ready' | 'error';
|
||||
};
|
||||
|
||||
const publicWorks = [
|
||||
@ -112,7 +107,6 @@ export function PlaygroundPage(props: {
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const isMountedRef = useRef(false);
|
||||
const pendingMediaModelRef = useRef('');
|
||||
const mediaEstimateSequenceRef = useRef(0);
|
||||
const resumedTaskIdsRef = useRef(new Set<string>());
|
||||
const activeMode = useMemo(() => modeOptions.find((item) => item.value === props.mode) ?? modeOptions[0], [props.mode]);
|
||||
const mediaUploadAcceptValue = sharedMediaUploadAcceptForMode(props.mode, videoMode);
|
||||
@ -142,10 +136,6 @@ export function PlaygroundPage(props: {
|
||||
supportsQualityControl: mediaCapabilities?.supportsQualityControl,
|
||||
});
|
||||
}, [mediaCapabilities, mediaSettings, mediaUploads, prompt, props.mode, selectedModel, videoMode]);
|
||||
const mediaEstimateSignature = useMemo(
|
||||
() => billingEstimateSignature(mediaEstimatePayload),
|
||||
[mediaEstimatePayload],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedModel((current) => {
|
||||
@ -186,35 +176,30 @@ export function PlaygroundPage(props: {
|
||||
useEffect(() => {
|
||||
const credential = activeApiKeySecret || props.token;
|
||||
if (props.mode === 'chat' || !credential || !mediaEstimatePayload) {
|
||||
mediaEstimateSequenceRef.current += 1;
|
||||
setMediaEstimate({ status: 'idle' });
|
||||
return;
|
||||
}
|
||||
const sequence = mediaEstimateSequenceRef.current + 1;
|
||||
mediaEstimateSequenceRef.current = sequence;
|
||||
const controller = new AbortController();
|
||||
setMediaEstimate((current) => ({ ...current, error: '', signature: mediaEstimateSignature, status: 'loading' }));
|
||||
let cancelled = false;
|
||||
setMediaEstimate((current) => ({ ...current, error: '', status: 'loading' }));
|
||||
const timer = window.setTimeout(() => {
|
||||
estimatePricing(credential, mediaEstimatePayload, controller.signal)
|
||||
estimatePricing(credential, mediaEstimatePayload)
|
||||
.then((estimate) => {
|
||||
if (controller.signal.aborted || sequence !== mediaEstimateSequenceRef.current) return;
|
||||
setMediaEstimate({ ...mediaEstimateFromResponse(estimate), signature: mediaEstimateSignature });
|
||||
if (cancelled) return;
|
||||
setMediaEstimate(mediaEstimateFromResponse(estimate));
|
||||
})
|
||||
.catch((err) => {
|
||||
if (controller.signal.aborted || sequence !== mediaEstimateSequenceRef.current) return;
|
||||
const unavailable = err instanceof GatewayApiError && err.details.code === 'pricing_unavailable';
|
||||
if (cancelled) return;
|
||||
setMediaEstimate({
|
||||
error: err instanceof Error ? err.message : unavailable ? '价格不可用' : '预计扣费计算失败',
|
||||
signature: mediaEstimateSignature,
|
||||
status: unavailable ? 'unavailable' : 'error',
|
||||
error: err instanceof Error ? err.message : '预计扣费计算失败',
|
||||
status: 'error',
|
||||
});
|
||||
});
|
||||
}, MEDIA_ESTIMATE_DEBOUNCE_MS);
|
||||
}, 260);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearTimeout(timer);
|
||||
controller.abort();
|
||||
};
|
||||
}, [activeApiKeySecret, mediaEstimateSignature, props.mode, props.token]);
|
||||
}, [activeApiKeySecret, mediaEstimatePayload, props.mode, props.token]);
|
||||
|
||||
useEffect(() => {
|
||||
if (props.mode === 'image') {
|
||||
@ -328,34 +313,11 @@ export function PlaygroundPage(props: {
|
||||
setMediaMessage(runMode === 'video' ? '请输入视频提示词。' : '请输入图片提示词。');
|
||||
return;
|
||||
}
|
||||
|
||||
const localId = newLocalId();
|
||||
const runUploads = sanitizeMediaRunUploads(overrides?.uploads ?? mediaUploads);
|
||||
const runVideoMode = overrides?.videoMode ?? videoMode;
|
||||
const runModelOption = modelOptions.find((item) => item.value === runModel);
|
||||
const runEstimatePayload = buildMediaEstimatePayload(runMode, runModel, trimmedPrompt, runSettings, runUploads, runVideoMode, {
|
||||
supportsQualityControl: runModelOption
|
||||
? deriveMediaModelCapabilities(runModelOption.models, runMode, runVideoMode, runSettings.resolution).supportsQualityControl
|
||||
: mediaCapabilities?.supportsQualityControl,
|
||||
});
|
||||
const runEstimateSignature = billingEstimateSignature(runEstimatePayload);
|
||||
if (!overrides && !mediaEstimateAllowsProduction(mediaEstimate, runEstimateSignature)) {
|
||||
setMediaMessage(mediaEstimate.status === 'unavailable'
|
||||
? '当前参数没有可用价格,已阻止生产生成。'
|
||||
: '请等待当前参数的预计费用计算完成后再生成。');
|
||||
return;
|
||||
}
|
||||
if (overrides) {
|
||||
try {
|
||||
await estimatePricing(credential, runEstimatePayload);
|
||||
} catch (error) {
|
||||
const unavailable = error instanceof GatewayApiError && error.details.code === 'pricing_unavailable';
|
||||
setMediaMessage(unavailable
|
||||
? '当前参数没有可用价格,已阻止生产生成。'
|
||||
: `预计扣费计算失败,未提交生产任务:${error instanceof Error ? error.message : '未知错误'}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const localId = newLocalId();
|
||||
const modelLabel = runModelOption?.label ?? runModel;
|
||||
const run: MediaGenerationRun = {
|
||||
createdAt: new Date().toISOString(),
|
||||
@ -494,7 +456,6 @@ export function PlaygroundPage(props: {
|
||||
imageHasReference={effectiveImageHasReference}
|
||||
mediaSettings={mediaSettings}
|
||||
mediaEstimate={mediaEstimate}
|
||||
submitDisabled={!mediaEstimateAllowsProduction(mediaEstimate, mediaEstimateSignature)}
|
||||
mediaCapabilities={mediaCapabilities}
|
||||
uploadAccept={mediaUploadAcceptValue}
|
||||
uploadMessage={mediaUploadMessage}
|
||||
@ -723,7 +684,6 @@ function Composer(props: {
|
||||
imageHasReference?: boolean;
|
||||
mediaCapabilities?: MediaModelCapabilities;
|
||||
mediaEstimate?: MediaEstimateState;
|
||||
submitDisabled?: boolean;
|
||||
mediaSettings?: MediaGenerationSettings;
|
||||
mode: PlaygroundMode;
|
||||
modelOptions: ModelOption[];
|
||||
@ -831,7 +791,7 @@ function Composer(props: {
|
||||
<span>{mediaEstimateText(props.mediaEstimate)}</span>
|
||||
</span>
|
||||
)}
|
||||
<Button type="button" size="icon" className="composerMediaSendButton" aria-label="发送测试" disabled={props.submitDisabled} onClick={props.onSubmit}>
|
||||
<Button type="button" size="icon" className="composerMediaSendButton" aria-label="发送测试" onClick={props.onSubmit}>
|
||||
<ArrowUp size={24} />
|
||||
</Button>
|
||||
</div>
|
||||
@ -868,17 +828,12 @@ function buildMediaEstimatePayload(
|
||||
};
|
||||
}
|
||||
|
||||
export function mediaEstimateFromResponse(response: GatewayPricingEstimate): MediaEstimateState {
|
||||
const amount = numericFromUnknown(response.totalAmount) ?? estimateItemsTotal(response.items);
|
||||
const explicitFree = amount === 0 && response.items.length > 0 && response.items.every((item) => item.isFree === true);
|
||||
function mediaEstimateFromResponse(response: GatewayPricingEstimate): MediaEstimateState {
|
||||
return {
|
||||
amount,
|
||||
reservationAmount: numericFromUnknown(response.reservationAmount),
|
||||
candidateCount: response.candidateCount,
|
||||
amount: numericFromUnknown(response.totalAmount) ?? estimateItemsTotal(response.items),
|
||||
currency: response.currency || estimateItemsCurrency(response.items),
|
||||
pricingVersion: response.pricingVersion,
|
||||
resolver: response.resolver,
|
||||
status: explicitFree ? 'free' : 'ready',
|
||||
status: 'ready',
|
||||
};
|
||||
}
|
||||
|
||||
@ -892,79 +847,21 @@ function estimateItemsCurrency(items: GatewayPricingEstimate['items']) {
|
||||
}
|
||||
|
||||
function mediaEstimateText(estimate: MediaEstimateState) {
|
||||
if (estimate.status === 'loading') return '计算中';
|
||||
if (estimate.status === 'unavailable') return '价格不可用';
|
||||
if (estimate.status === 'error') return '估价失败';
|
||||
if (estimate.status === 'free') return '免费';
|
||||
if (estimate.amount === undefined) return '--';
|
||||
const amount = formatEstimateAmount(estimate.amount);
|
||||
const reservation = estimate.reservationAmount;
|
||||
if (reservation !== undefined && reservation > estimate.amount) {
|
||||
return `预计 ${amount} · 冻结上限 ${formatEstimateAmount(reservation)}`;
|
||||
}
|
||||
return `预计 ${amount}`;
|
||||
return formatEstimateAmount(estimate.amount);
|
||||
}
|
||||
|
||||
function mediaEstimateHint(estimate: MediaEstimateState) {
|
||||
if (estimate.status === 'unavailable' && estimate.error) return `价格不可用:${estimate.error}`;
|
||||
if (estimate.status === 'error' && estimate.error) return `预计扣费计算失败:${estimate.error}`;
|
||||
if (estimate.status === 'free') return '当前计价规则已显式标记为免费';
|
||||
return '扣费为预计,实际扣费以账单为准';
|
||||
}
|
||||
|
||||
function mediaEstimateAriaLabel(estimate: MediaEstimateState) {
|
||||
if (estimate.status === 'error') return estimate.error ? `预计扣费计算失败:${estimate.error}` : '预计扣费计算失败';
|
||||
if (estimate.status === 'unavailable') return estimate.error ? `价格不可用:${estimate.error}` : '价格不可用';
|
||||
if (estimate.status === 'free') return '当前请求免费';
|
||||
if (estimate.amount === undefined) return '预计扣费估算中';
|
||||
return `预计扣费 ${formatEstimateAmount(estimate.amount)} ${estimate.currency || 'resource'}`;
|
||||
}
|
||||
|
||||
export function mediaEstimateAllowsProduction(estimate: MediaEstimateState, signature: string) {
|
||||
return estimate.signature === signature && (estimate.status === 'ready' || estimate.status === 'free');
|
||||
}
|
||||
|
||||
export function billingEstimateSignature(payload: Record<string, unknown> | null) {
|
||||
if (!payload) return '';
|
||||
const content = Array.isArray(payload.content)
|
||||
? payload.content.map((item) => {
|
||||
const record = item && typeof item === 'object' ? item as Record<string, unknown> : {};
|
||||
return {
|
||||
duration: record.duration,
|
||||
role: record.role,
|
||||
type: record.type,
|
||||
hasAudio: Boolean(record.audio_url),
|
||||
hasImage: Boolean(record.image_url),
|
||||
hasVideo: Boolean(record.video_url),
|
||||
};
|
||||
})
|
||||
: undefined;
|
||||
const signature = {
|
||||
kind: payload.kind,
|
||||
model: payload.model,
|
||||
n: payload.n ?? payload.count ?? payload.batch_size ?? payload.batchSize,
|
||||
quality: payload.quality,
|
||||
size: payload.size,
|
||||
resolution: payload.resolution,
|
||||
duration: payload.duration,
|
||||
audio: payload.audio,
|
||||
maxCompletionTokens: payload.max_completion_tokens,
|
||||
maxOutputTokens: payload.max_output_tokens,
|
||||
maxTokens: payload.max_tokens,
|
||||
content,
|
||||
imageCount: pricingReferenceCount(payload, ['image', 'images', 'image_url', 'image_urls', 'referenceImage', 'reference_image']),
|
||||
};
|
||||
return JSON.stringify(signature);
|
||||
}
|
||||
|
||||
function pricingReferenceCount(payload: Record<string, unknown>, keys: string[]) {
|
||||
return keys.reduce((count, key) => {
|
||||
const value = payload[key];
|
||||
if (Array.isArray(value)) return count + value.length;
|
||||
return count + (value ? 1 : 0);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function formatEstimateAmount(value: number) {
|
||||
if (!Number.isFinite(value)) return '--';
|
||||
const digits = Math.abs(value) > 0 && Math.abs(value) < 0.01 ? 6 : 2;
|
||||
|
||||
@ -1,162 +0,0 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { AlertTriangle, ReceiptText, RefreshCw, RotateCcw } from 'lucide-react';
|
||||
import type { BillingSettlement } from '@easyai-ai-gateway/contracts';
|
||||
import { listBillingSettlements, retryBillingSettlement } from '../../api';
|
||||
import { Badge, Button, Card, CardContent, CardHeader, CardTitle, ConfirmDialog, Select, Table, TableCell, TableHead, TableRow } from '../../components/ui';
|
||||
|
||||
export function BillingSettlementsPanel(props: { token: string }) {
|
||||
const [items, setItems] = useState<BillingSettlement[]>([]);
|
||||
const [status, setStatus] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [retrying, setRetrying] = useState('');
|
||||
const [confirming, setConfirming] = useState<BillingSettlement | null>(null);
|
||||
const [message, setMessage] = useState('');
|
||||
|
||||
const load = useCallback(async (signal?: AbortSignal) => {
|
||||
setLoading(true);
|
||||
setMessage('');
|
||||
try {
|
||||
const result = await listBillingSettlements(props.token, { status, pageSize: 100, signal });
|
||||
setItems(result.items);
|
||||
} catch (error) {
|
||||
if (signal?.aborted) return;
|
||||
setMessage(error instanceof Error ? error.message : '结算队列加载失败');
|
||||
} finally {
|
||||
if (!signal?.aborted) setLoading(false);
|
||||
}
|
||||
}, [props.token, status]);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
void load(controller.signal);
|
||||
return () => controller.abort();
|
||||
}, [load]);
|
||||
|
||||
async function retry(item: BillingSettlement) {
|
||||
setRetrying(item.id);
|
||||
setMessage('');
|
||||
try {
|
||||
await retryBillingSettlement(props.token, item.id);
|
||||
setMessage(`结算 ${shortId(item.id)} 已重新进入队列`);
|
||||
await load();
|
||||
} catch (error) {
|
||||
setMessage(error instanceof Error ? error.message : '结算重试失败');
|
||||
} finally {
|
||||
setRetrying('');
|
||||
}
|
||||
}
|
||||
|
||||
const exceptions = items.filter((item) => item.status === 'retryable_failed' || item.status === 'manual_review').length;
|
||||
return (
|
||||
<div className="pageStack">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="identityHeaderTitle">
|
||||
<div className="iconBox"><ReceiptText size={17} /></div>
|
||||
<div>
|
||||
<CardTitle>计费结算</CardTitle>
|
||||
<p className="mutedText">任务结果与账务状态相互独立;异常记录可审计重试,不会重新调用上游。</p>
|
||||
</div>
|
||||
<Badge variant={exceptions ? 'warning' : 'secondary'}>{exceptions ? `${exceptions} 条异常` : `${items.length} 条`}</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="tableActions">
|
||||
<Select size="sm" aria-label="结算状态" value={status} onChange={(event) => setStatus(event.target.value)}>
|
||||
<option value="">全部状态</option>
|
||||
<option value="pending">待处理</option>
|
||||
<option value="processing">处理中</option>
|
||||
<option value="retryable_failed">等待重试</option>
|
||||
<option value="manual_review">人工复核</option>
|
||||
<option value="completed">已完成</option>
|
||||
</Select>
|
||||
<Button type="button" size="sm" variant="outline" disabled={loading} onClick={() => void load()}>
|
||||
<RefreshCw size={14} />{loading ? '刷新中' : '刷新'}
|
||||
</Button>
|
||||
</div>
|
||||
{message && <p className="formMessage">{message}</p>}
|
||||
</CardContent>
|
||||
</Card>
|
||||
{items.length ? (
|
||||
<Table density="compact" className="identityDataTable">
|
||||
<TableRow>
|
||||
<TableHead>任务 / 动作</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>金额</TableHead>
|
||||
<TableHead>尝试</TableHead>
|
||||
<TableHead>异常分类</TableHead>
|
||||
<TableHead>操作</TableHead>
|
||||
</TableRow>
|
||||
{items.map((item) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell><span className="identityTableName"><strong>{shortId(item.taskId)}</strong><small>{item.action === 'settle' ? '扣费' : '释放冻结'} · {shortId(item.id)}</small></span></TableCell>
|
||||
<TableCell><Badge variant={statusVariant(item.status)}>{statusLabel(item.status)}</Badge></TableCell>
|
||||
<TableCell>{formatAmount(item.amount)} {item.currency}</TableCell>
|
||||
<TableCell>{item.attempts} / 20</TableCell>
|
||||
<TableCell>{item.lastErrorCode || item.manualReviewReason || '-'}</TableCell>
|
||||
<TableCell>
|
||||
{(item.status === 'retryable_failed' || item.status === 'manual_review') ? (
|
||||
<Button type="button" size="xs" variant="outline" disabled={retrying === item.id} onClick={() => setConfirming(item)}>
|
||||
<RotateCcw size={13} />{retrying === item.id ? '提交中' : retryActionLabel(item)}
|
||||
</Button>
|
||||
) : '-'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</Table>
|
||||
) : (
|
||||
<Card><CardContent className="emptyState"><AlertTriangle size={18} /><strong>{loading ? '正在加载结算队列' : '暂无结算记录'}</strong><span>可切换状态筛选,查看待处理与人工复核记录。</span></CardContent></Card>
|
||||
)}
|
||||
<ConfirmDialog
|
||||
open={Boolean(confirming)}
|
||||
loading={Boolean(confirming && retrying === confirming.id)}
|
||||
title={confirming ? `${retryActionLabel(confirming)}这条计费记录?` : '处理计费记录?'}
|
||||
description={confirming ? retryActionDescription(confirming) : ''}
|
||||
confirmLabel={confirming ? retryActionLabel(confirming) : '确认'}
|
||||
onCancel={() => setConfirming(null)}
|
||||
onConfirm={() => {
|
||||
if (!confirming) return;
|
||||
const item = confirming;
|
||||
void retry(item).finally(() => setConfirming(null));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function statusLabel(status: string) {
|
||||
return ({ pending: '待处理', processing: '处理中', retryable_failed: '等待重试', manual_review: '人工复核', completed: '已完成' } as Record<string, string>)[status] ?? status;
|
||||
}
|
||||
|
||||
function statusVariant(status: string): 'secondary' | 'warning' | 'destructive' | 'success' | 'outline' {
|
||||
if (status === 'completed') return 'success';
|
||||
if (status === 'manual_review') return 'destructive';
|
||||
if (status === 'retryable_failed') return 'warning';
|
||||
if (status === 'processing') return 'outline';
|
||||
return 'secondary';
|
||||
}
|
||||
|
||||
function formatAmount(value: number) {
|
||||
return new Intl.NumberFormat('zh-CN', { minimumFractionDigits: 0, maximumFractionDigits: 9 }).format(value);
|
||||
}
|
||||
|
||||
function retryActionLabel(item: BillingSettlement) {
|
||||
if (item.status !== 'manual_review') return '重试';
|
||||
if (item.manualReviewReason === 'upstream_submission_unknown' && item.action === 'release') return '确认释放';
|
||||
if (item.manualReviewReason === 'historical_success_without_charge' && item.action === 'settle') return '确认结算';
|
||||
return '重新入队';
|
||||
}
|
||||
|
||||
function retryActionDescription(item: BillingSettlement) {
|
||||
if (item.manualReviewReason === 'upstream_submission_unknown' && item.action === 'release') {
|
||||
return '上游提交结果不明。确认后只会释放当前冻结,不会再次调用上游;请先完成外部核查。';
|
||||
}
|
||||
if (item.manualReviewReason === 'historical_success_without_charge' && item.action === 'settle') {
|
||||
return `确认后将按历史快照结算 ${formatAmount(item.amount)} ${item.currency},不会重新调用上游。`;
|
||||
}
|
||||
return '确认后会将该记录重新放入结算队列,不会重新调用上游。';
|
||||
}
|
||||
|
||||
function shortId(value: string) {
|
||||
return value.length > 12 ? `${value.slice(0, 8)}…${value.slice(-4)}` : value;
|
||||
}
|
||||
@ -221,17 +221,7 @@ function ModeRuleEditor(props: {
|
||||
<Select size="sm" value={rule.status ?? 'active'} onChange={(event) => patch({ status: event.target.value })}>{statuses.map((item) => <option key={item} value={item}>{item}</option>)}</Select>
|
||||
</PricingFormRow>
|
||||
<PricingFormRow label={`基础单价/${unitLabel(rule.unit)}`}>
|
||||
<Input disabled={rule.isFree} size="sm" min="0" step="0.000000001" type="number" value={rule.basePrice} onChange={(event) => patch({ basePrice: Number(event.target.value) })} />
|
||||
</PricingFormRow>
|
||||
<PricingFormRow label="显式免费">
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={rule.isFree ?? false}
|
||||
onChange={(event) => patch({ isFree: event.target.checked, basePrice: event.target.checked ? 0 : rule.basePrice })}
|
||||
/>
|
||||
<span>允许该规则以零费用运行</span>
|
||||
</label>
|
||||
<Input size="sm" min="0" step="0.0001" type="number" value={rule.basePrice} onChange={(event) => patch({ basePrice: Number(event.target.value) })} />
|
||||
</PricingFormRow>
|
||||
<PricingReadonlyRow label="计价单位" value={unitLabel(rule.unit)} />
|
||||
<PricingReadonlyRow label="计算方式" value={calculatorLabel(rule.calculatorType)} />
|
||||
@ -321,32 +311,11 @@ function TextRuleEditor(props: {
|
||||
<PricingFormRow label="状态">
|
||||
<Select size="sm" value={props.rule.status ?? 'active'} onChange={(event) => props.onChange({ ...props.rule, status: event.target.value })}>{statuses.map((item) => <option key={item} value={item}>{item}</option>)}</Select>
|
||||
</PricingFormRow>
|
||||
<PricingFormRow label="显式免费">
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={props.rule.isFree ?? false}
|
||||
onChange={(event) => {
|
||||
const isFree = event.target.checked;
|
||||
props.onChange({
|
||||
...props.rule,
|
||||
isFree,
|
||||
basePrice: isFree ? 0 : props.rule.basePrice,
|
||||
formulaConfig: isFree
|
||||
? { ...(props.rule.formulaConfig ?? {}), inputTokenPrice: 0, cachedInputTokenPrice: 0, outputTokenPrice: 0 }
|
||||
: props.rule.formulaConfig,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<span>允许该规则以零费用运行</span>
|
||||
</label>
|
||||
</PricingFormRow>
|
||||
<PricingFormRow label="输入单价/1K tokens">
|
||||
<Input
|
||||
disabled={props.rule.isFree}
|
||||
size="sm"
|
||||
min="0"
|
||||
step="0.000000001"
|
||||
step="0.0001"
|
||||
type="number"
|
||||
value={prices.inputTokenPrice}
|
||||
onChange={(event) =>
|
||||
@ -360,10 +329,9 @@ function TextRuleEditor(props: {
|
||||
</PricingFormRow>
|
||||
<PricingFormRow label="缓存输入单价/1K tokens">
|
||||
<Input
|
||||
disabled={props.rule.isFree}
|
||||
size="sm"
|
||||
min="0"
|
||||
step="0.000000001"
|
||||
step="0.0001"
|
||||
type="number"
|
||||
value={prices.cachedInputTokenPrice}
|
||||
onChange={(event) =>
|
||||
@ -377,10 +345,9 @@ function TextRuleEditor(props: {
|
||||
</PricingFormRow>
|
||||
<PricingFormRow label="输出单价/1K tokens">
|
||||
<Input
|
||||
disabled={props.rule.isFree}
|
||||
size="sm"
|
||||
min="0"
|
||||
step="0.000000001"
|
||||
step="0.0001"
|
||||
type="number"
|
||||
value={prices.outputTokenPrice}
|
||||
onChange={(event) =>
|
||||
|
||||
@ -225,7 +225,6 @@ function ruleToInput(rule: PricingRule): PricingRuleInput {
|
||||
resourceType: rule.resourceType,
|
||||
unit: rule.unit,
|
||||
basePrice: rule.basePrice,
|
||||
isFree: rule.isFree,
|
||||
currency: rule.currency,
|
||||
baseWeight: rule.baseWeight ?? {},
|
||||
dynamicWeight: rule.dynamicWeight ?? {},
|
||||
@ -235,8 +234,6 @@ function ruleToInput(rule: PricingRule): PricingRuleInput {
|
||||
priority: rule.priority,
|
||||
status: rule.status,
|
||||
metadata: rule.metadata ?? {},
|
||||
effectiveFrom: rule.effectiveFrom,
|
||||
effectiveTo: rule.effectiveTo,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -1,65 +0,0 @@
|
||||
# 计费业务流程 v2
|
||||
|
||||
## 正常流程
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant UI as 前端
|
||||
participant API as Gateway API
|
||||
participant DB as PostgreSQL
|
||||
participant UP as 上游模型
|
||||
participant BW as 结算 Worker
|
||||
|
||||
UI->>API: 参数变化后的只读估价
|
||||
API->>DB: 读取有效计价规则
|
||||
API-->>UI: 首选预计费用 + 候选最大冻结额
|
||||
UI->>API: 正式提交(可带 Idempotency-Key)
|
||||
API->>DB: 重新计价、保存快照并冻结最大金额
|
||||
API->>DB: 领取执行租约
|
||||
API->>UP: 提交已预处理请求
|
||||
UP-->>API: 成功结果与实际用量
|
||||
API->>DB: 原子写 Attempt、任务成功、最终金额和 settle Outbox
|
||||
API-->>UI: succeeded + billingStatus=pending
|
||||
BW->>DB: SKIP LOCKED 领取 Outbox
|
||||
BW->>DB: 原子扣费、释放冻结、完成 Outbox
|
||||
```
|
||||
|
||||
## 失败与取消
|
||||
|
||||
上游提交前失败、上游明确失败和用户取消均在任务终态事务内写入 `release` Outbox。Worker 使用冻结流水的幂等键释放资金。若上游请求已经进入 `submitting`,或成功响应已返回但任务终态尚未提交,租约接管必须停止自动执行;任务和对应 Outbox 转人工复核并保留冻结。
|
||||
|
||||
人工复核记录在管理端明确显示后续动作。上游结果不明的记录只能由 Manager 在完成外部核查后确认释放;历史成功未扣费且金额快照可用的记录可由 Manager 明确确认结算。两类操作均要求确认、`Idempotency-Key` 和审计记录,且不会再次调用上游。
|
||||
|
||||
## 即时估价
|
||||
|
||||
前端仅对影响计费的字段生成稳定签名,包括模型、生成模式、数量、质量、分辨率、时长、音频、参考素材和 Token 上限。签名变化后等待 350ms,取消旧请求,并用递增序号阻止乱序响应覆盖。
|
||||
|
||||
界面必须区分:
|
||||
|
||||
- 计算中;
|
||||
- 显式免费;
|
||||
- 正常预计费用与冻结上限;
|
||||
- 价格不可用;
|
||||
- 估价请求失败。
|
||||
|
||||
价格不可用或估价失败时禁止生产生成;模拟模式仍允许执行并显示原因。
|
||||
|
||||
## Worker 参数
|
||||
|
||||
- 轮询间隔:1 秒。
|
||||
- 单批数量:50。
|
||||
- 处理锁过期:120 秒。
|
||||
- 最大自动尝试:20 次。
|
||||
- 退避上限:15 分钟。
|
||||
|
||||
这些参数是安全默认值,可通过受控配置调整,但不得改变幂等键、快照和账务不变量。
|
||||
|
||||
## 发布与回滚
|
||||
|
||||
`BILLING_ENGINE_MODE` 支持:
|
||||
|
||||
- `observe`:并行计算并记录差异,不改变旧路径账务结果。
|
||||
- `enforce`:启用 v2 缺价拒绝、冻结与结算闭环。
|
||||
- `hold`:在调用上游前拒绝新的生产任务,已有 Outbox 继续处理。
|
||||
|
||||
发布先运行 24 小时 observe,补齐所有缺价和非法规则,再以 10% 流量启用 enforce,确认没有重复扣费、长期冻结和异常差额后扩大到 100%。回滚只切换到 hold,不回滚数据库增量,也不恢复隐式零价。
|
||||
@ -1,86 +0,0 @@
|
||||
# ADR-002:计费正确性闭环与即时估价
|
||||
|
||||
## 状态
|
||||
|
||||
Accepted
|
||||
|
||||
## 日期
|
||||
|
||||
2026-07-20
|
||||
|
||||
## 背景
|
||||
|
||||
Gateway 已具备 PostgreSQL 本地钱包、任务冻结、任务扣费和钱包流水,但任务终态与钱包结算分属不同事务。进程在任务成功写入后、扣费前或释放前退出时,可能留下成功未扣费或永久冻结。现有估价只计算首选候选,缺价会退化为普通零价,且文本输出上限和精度规则与正式结算并不完全一致。
|
||||
|
||||
Comfy 的业务流程证明了三项做法有参考价值:任务状态与计费状态分离;计费操作使用独立令牌和过期接管;参数变化后立即生成只读估价。Gateway 不采用 Comfy 的三十分钟虚拟预占、结算时刷新最新价格、公开估价接口和负余额策略,因为这些做法会削弱本地钱包作为唯一账务事实源的约束。
|
||||
|
||||
## 决策
|
||||
|
||||
### 账务事实源与状态
|
||||
|
||||
Gateway PostgreSQL 本地钱包是唯一账务事实源,不与 Comfy 或其他系统双写。任务状态与计费状态独立:生成成功可以处于 `succeeded + pending`,结算失败不得篡改生成结果。
|
||||
|
||||
计费状态为:
|
||||
|
||||
- `not_started`:尚未产生账务动作。
|
||||
- `pending`:结算或释放已进入 Outbox。
|
||||
- `processing`:Worker 已领取账务动作。
|
||||
- `settled`:已幂等扣费并释放冻结。
|
||||
- `released`:失败或取消任务的冻结已释放。
|
||||
- `retryable_failed`:可自动重试。
|
||||
- `manual_review`:上游结果不明或连续失败,需要人工处理。
|
||||
- `not_required`:模拟任务、无本地钱包用户或其他明确无需计费的任务。
|
||||
|
||||
### 统一计价
|
||||
|
||||
估价、提交冻结和最终账单统一使用 `effective-pricing-v2`。规则优先级为平台模型、平台、基准模型;只接受处于生效期内、状态有效、币种为 `resource` 且计算器受支持的规则。普通零价、缺价或失效规则返回 `503 pricing_unavailable`;只有 `isFree=true` 的显式免费规则允许零费用。
|
||||
|
||||
金额决策使用 9 位十进制定点数。文本费用按实际 Token 比例计算,不按千 Token 向上取整;视频以五秒为一个单位向上取整。估价对所有有效候选计算并以最大值作为冻结上限,首选候选费用继续作为 `totalAmount`。
|
||||
|
||||
前端估价是只读提示,不构成价格承诺。正式提交必须重新计价、冻结,并保存不可变计价快照。
|
||||
|
||||
### 结算闭环
|
||||
|
||||
成功 Attempt、任务成功结果、最终金额、计价快照和 `settle` Outbox 在同一个数据库事务写入;失败和取消任务原子写入 `release` Outbox。Worker 使用 `FOR UPDATE SKIP LOCKED` 批量领取,锁超时后允许其他实例接管,并在钱包行锁事务中完成幂等扣费或释放、任务计费状态和 Outbox 完成状态。
|
||||
|
||||
上游提交状态分为 `not_submitted`、`submitting` 和 `response_received`。处于 `submitting` 且结果不明的任务保留冻结并进入人工复核,不自动重试上游。
|
||||
|
||||
### 请求幂等与执行租约
|
||||
|
||||
生成接口可选接受单值 `Idempotency-Key`。数据库只保存键摘要以及规范化请求摘要;同键同请求按协议重放,同键不同请求返回 `409 idempotency_key_reused`,流式重复返回 `409 idempotency_stream_replay_unsupported`。日志和审计记录不得包含原始键或完整请求正文。
|
||||
|
||||
任务由带随机 `execution_token` 的五分钟租约领取,每三十秒续约。运行态更新和终态提交必须匹配令牌,旧 Worker 不得覆盖接管后的新结果。
|
||||
|
||||
## 账务不变量
|
||||
|
||||
1. `balance >= frozen_balance >= 0`。
|
||||
2. 同一任务和币种最多存在一笔有效扣费。
|
||||
3. 一个冻结必须最终被结算消费或释放,不能同时发生两者。
|
||||
4. 正式任务缺价时不得调用上游。
|
||||
5. 最终扣费只能使用提交时保存的计价快照,不能读取更新后的价格。
|
||||
6. 估价不得创建任务、冻结余额或写钱包流水。
|
||||
7. 历史成功未扣费任务只标记人工复核,不自动追扣。
|
||||
|
||||
## 异常矩阵
|
||||
|
||||
| 发生点 | 任务状态 | 计费状态 | 自动动作 |
|
||||
| --- | --- | --- | --- |
|
||||
| 计价失败 | 未创建或失败 | `not_started` | 返回 `pricing_unavailable`,不调用上游 |
|
||||
| 冻结失败 | 已创建 | `not_started` | 返回余额不足,不调用上游 |
|
||||
| 上游提交前失败 | 失败 | `pending` | Outbox 释放冻结 |
|
||||
| 上游提交结果不明 | 运行中或失败 | `manual_review` | 保留冻结,禁止自动重试上游 |
|
||||
| 上游明确失败 | 失败 | `pending` | Outbox 释放冻结 |
|
||||
| 生成成功、结算待执行 | 成功 | `pending` | 返回成功结果,Worker 异步结算 |
|
||||
| 结算暂时失败 | 成功 | `retryable_failed` | 指数退避重试 |
|
||||
| 结算连续失败 | 成功 | `manual_review` | 管理员审计化重试 |
|
||||
|
||||
## 历史数据策略
|
||||
|
||||
迁移只分类,不产生历史补扣:已有任务扣费流水的成功任务标记 `settled`;失败或取消任务的遗留冻结进入释放队列;成功但无扣费流水的生产任务进入 `manual_review`。
|
||||
|
||||
## 后果
|
||||
|
||||
- 成功结果和账务暂时失败可以独立呈现,调用方必须读取 `billingStatus`。
|
||||
- 计价规则编辑器必须要求显式免费,并在规则不可用时阻止生产生成。
|
||||
- 结算具有最终一致性,因此需要积压、延迟、重试和人工复核监控。
|
||||
- 数据库增量在回滚时保留;应用切换到 `hold` 后拒绝新的生产任务,同时继续运行结算 Worker。
|
||||
@ -16,7 +16,7 @@
|
||||
|
||||
生产 CD 由部署仓的 root-owned dispatcher 执行。它在相同源码 SHA 上同时等待 `ci / verify (push)` 与本次新产生的 `release-ci / verify-tag (push)` 成功,然后只运行部署仓中固定的 BuildKit 构建、Trivy 镜像扫描、digest 发布、备份、迁移、健康检查和回滚命令;不得执行 Tag checkout 中的 `pnpm`、测试、shell 脚本或应用程序。
|
||||
|
||||
provision 安装 checksum-pinned 的官方静态 ShellCheck `0.11.0` 和 Docker Compose `5.3.1`,不会依赖宿主是否预装这些命令。所有下载都在使用前校验固定 SHA-256,并在精确 Node Job 镜像内实际执行版本检查。PostgreSQL 16 集成库作为固定 digest 的 Actions service container 运行,PR Job 不接收宿主或内层 Docker API。
|
||||
provision 安装 checksum-pinned 的官方静态 ShellCheck `0.11.0` 和 Docker Compose `5.3.1`,不会依赖宿主是否预装这些命令。所有下载都在使用前校验固定 SHA-256,并在精确 Node Job 镜像内实际执行版本检查。
|
||||
|
||||
## 首次安装或修复 CI Runner
|
||||
|
||||
@ -34,14 +34,6 @@ RUNNER_REGISTRATION_TOKEN='<一次性 Token>' ./scripts/provision-ci-runner.sh
|
||||
./scripts/provision-ci-runner.sh
|
||||
```
|
||||
|
||||
宿主无法直连 Docker Hub 时,可使用可信 HTTPS 镜像代理;Runner 来源仍必须携带脚本中完全相同的 manifest digest,内层 daemon 拉取的 Job 与 service 镜像也会继续校验工作流固定的 digest:
|
||||
|
||||
```bash
|
||||
CI_RUNNER_IMAGE_SOURCE='docker.m.daocloud.io/gitea/runner:2.0.0-dind-rootless@sha256:5b7b625ff773d0ee761788c47582503ec1b241fa5b81edebad48a57e663f4f3a' \
|
||||
CI_NESTED_DOCKER_REGISTRY_MIRROR='https://docker.m.daocloud.io' \
|
||||
./scripts/provision-ci-runner.sh
|
||||
```
|
||||
|
||||
注册 Token 通过交互 stdin 送给短命注册容器,不出现在宿主或容器的命令行参数中;provision 随后立即从环境中清除它,长期 systemd unit 也不包含 Token。不要输出或复制 `/data/.runner` 内容。脚本会停用旧 host Runner,并确保旧 Runner 用户和 v2 遗留用户不属于 `docker`/`sudo` 组。
|
||||
|
||||
默认磁盘门禁可用环境变量提高,但生产环境不应降低:
|
||||
|
||||
@ -184,7 +184,6 @@ export interface PricingRule {
|
||||
| string;
|
||||
unit: '1k_tokens' | 'image' | '5s' | 'second' | 'character_1k' | 'item' | string;
|
||||
basePrice: number;
|
||||
isFree: boolean;
|
||||
currency: 'resource' | 'credit' | 'cny' | 'usd' | string;
|
||||
baseWeight?: Record<string, unknown>;
|
||||
dynamicWeight?: Record<string, unknown>;
|
||||
@ -194,8 +193,6 @@ export interface PricingRule {
|
||||
priority: number;
|
||||
status: 'active' | 'deprecated' | 'hidden' | string;
|
||||
metadata?: Record<string, unknown>;
|
||||
effectiveFrom?: string;
|
||||
effectiveTo?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
@ -206,7 +203,6 @@ export interface PricingRuleInput {
|
||||
resourceType: string;
|
||||
unit: string;
|
||||
basePrice: number;
|
||||
isFree?: boolean;
|
||||
currency?: string;
|
||||
baseWeight?: Record<string, unknown>;
|
||||
dynamicWeight?: Record<string, unknown>;
|
||||
@ -216,8 +212,6 @@ export interface PricingRuleInput {
|
||||
priority?: number;
|
||||
status?: 'active' | 'deprecated' | 'hidden' | string;
|
||||
metadata?: Record<string, unknown>;
|
||||
effectiveFrom?: string;
|
||||
effectiveTo?: string;
|
||||
}
|
||||
|
||||
export interface PricingRuleSet {
|
||||
@ -554,11 +548,7 @@ export interface GatewayPricingEstimate {
|
||||
items: GatewayPricingEstimateItem[];
|
||||
resolver: string;
|
||||
totalAmount?: number;
|
||||
reservationAmount?: number;
|
||||
currency?: string;
|
||||
candidateCount: number;
|
||||
pricingVersion: string;
|
||||
requestFingerprint: string;
|
||||
}
|
||||
|
||||
export interface GatewayWalletAccount {
|
||||
@ -1156,15 +1146,6 @@ export interface GatewayTask {
|
||||
metrics?: Record<string, unknown>;
|
||||
billingSummary?: Record<string, unknown>;
|
||||
finalChargeAmount?: number;
|
||||
billingVersion?: string;
|
||||
billingStatus?: 'not_started' | 'pending' | 'processing' | 'settled' | 'released' | 'retryable_failed' | 'manual_review' | 'not_required' | string;
|
||||
billingCurrency?: string;
|
||||
pricingSnapshot?: Record<string, unknown>;
|
||||
requestFingerprint?: string;
|
||||
reservationAmount?: number;
|
||||
executionLeaseExpiresAt?: string;
|
||||
billingUpdatedAt?: string;
|
||||
billingSettledAt?: string;
|
||||
responseStartedAt?: string;
|
||||
responseFinishedAt?: string;
|
||||
responseDurationMs?: number;
|
||||
@ -1221,49 +1202,12 @@ export interface GatewayTaskAttempt {
|
||||
responseStartedAt?: string;
|
||||
responseFinishedAt?: string;
|
||||
responseDurationMs?: number;
|
||||
pricingSnapshot?: Record<string, unknown>;
|
||||
requestFingerprint?: string;
|
||||
upstreamSubmissionStatus?: 'not_submitted' | 'submitting' | 'response_received' | string;
|
||||
upstreamSubmissionUpdatedAt?: string;
|
||||
errorCode?: string;
|
||||
errorMessage?: string;
|
||||
startedAt: string;
|
||||
finishedAt?: string;
|
||||
}
|
||||
|
||||
export interface BillingSettlement {
|
||||
id: string;
|
||||
taskId: string;
|
||||
action: 'settle' | 'release' | string;
|
||||
amount: number;
|
||||
currency: string;
|
||||
status: 'pending' | 'processing' | 'retryable_failed' | 'completed' | 'manual_review' | string;
|
||||
attempts: number;
|
||||
nextAttemptAt: string;
|
||||
lockedBy?: string;
|
||||
lockedAt?: string;
|
||||
lastErrorCode?: string;
|
||||
lastErrorMessage?: string;
|
||||
manualReviewReason?: string;
|
||||
pricingSnapshot?: Record<string, unknown>;
|
||||
payload?: Record<string, unknown>;
|
||||
completedAt?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface BillingSettlementListResponse {
|
||||
items: BillingSettlement[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
export interface BillingSettlementRetryResponse {
|
||||
settlement: BillingSettlement;
|
||||
auditLog?: GatewayAuditLog;
|
||||
}
|
||||
|
||||
export interface GatewayTaskEvent {
|
||||
id: string;
|
||||
taskId: string;
|
||||
|
||||
@ -6,17 +6,15 @@ settings:
|
||||
|
||||
overrides:
|
||||
'@babel/core@<=7.29.0': 7.29.6
|
||||
'@nx/js>picomatch': 4.0.5
|
||||
'@nx/vite>picomatch': 4.0.5
|
||||
'@nx/workspace>picomatch': 4.0.5
|
||||
axios@<1.18.0: 1.18.0
|
||||
brace-expansion@>=2.0.0 <2.1.2: 2.1.2
|
||||
dompurify@<=3.4.10: 3.4.11
|
||||
esbuild@>=0.27.3 <0.28.1: 0.28.1
|
||||
form-data@>=4.0.0 <4.0.6: 4.0.6
|
||||
js-yaml@<4.3.0: 4.3.0
|
||||
js-yaml@<3.15.0: 4.1.1
|
||||
mermaid@>=11.0.0-alpha.1 <=11.14.0: 11.15.0
|
||||
minimatch@>=9.0.0 <9.0.7: 9.0.7
|
||||
'@nx/js>picomatch': 4.0.5
|
||||
'@nx/vite>picomatch': 4.0.5
|
||||
'@nx/workspace>picomatch': 4.0.5
|
||||
tmp@<0.2.7: 0.2.7
|
||||
|
||||
importers:
|
||||
@ -2445,10 +2443,6 @@ packages:
|
||||
resolution: {integrity: sha512-4B/qKCfeE/ODUaAUpSwfzazo5x29WD4r3vXiWsB7I2mSDAihwEqKO+g8GELZUQSSAo5e1XTYh3ZVfLyxBc12nA==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
|
||||
agent-base@6.0.2:
|
||||
resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==}
|
||||
engines: {node: '>= 6.0.0'}
|
||||
|
||||
ajv@8.20.0:
|
||||
resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==}
|
||||
|
||||
@ -2497,8 +2491,8 @@ packages:
|
||||
asynckit@0.4.0:
|
||||
resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
|
||||
|
||||
axios@1.18.0:
|
||||
resolution: {integrity: sha512-E32NzpYKp++W7XRe52rHiXV2ehxmh3wbdgO7MHeFM+vqxLBYHzt0ElkiImtOBxtOmyp0yoC8C6uESVV84Y2/hw==}
|
||||
axios@1.16.0:
|
||||
resolution: {integrity: sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==}
|
||||
|
||||
babel-plugin-const-enum@1.2.0:
|
||||
resolution: {integrity: sha512-o1m/6iyyFnp9MRsK1dHF3bneqyf3AlM2q3A/YbgQr2pCat6B6XJVDv2TXqzfY2RYUi4mak6WAksSBPlyYGx9dg==}
|
||||
@ -2559,8 +2553,8 @@ packages:
|
||||
bl@4.1.0:
|
||||
resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==}
|
||||
|
||||
brace-expansion@2.1.2:
|
||||
resolution: {integrity: sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==}
|
||||
brace-expansion@2.1.0:
|
||||
resolution: {integrity: sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==}
|
||||
|
||||
brace-expansion@5.0.7:
|
||||
resolution: {integrity: sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==}
|
||||
@ -3190,10 +3184,6 @@ packages:
|
||||
html-void-elements@3.0.0:
|
||||
resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==}
|
||||
|
||||
https-proxy-agent@5.0.1:
|
||||
resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==}
|
||||
engines: {node: '>= 6'}
|
||||
|
||||
iconv-lite@0.6.3:
|
||||
resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@ -3291,8 +3281,8 @@ packages:
|
||||
js-tokens@9.0.1:
|
||||
resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==}
|
||||
|
||||
js-yaml@4.3.0:
|
||||
resolution: {integrity: sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==}
|
||||
js-yaml@4.1.1:
|
||||
resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==}
|
||||
hasBin: true
|
||||
|
||||
jsesc@3.1.0:
|
||||
@ -5830,7 +5820,6 @@ snapshots:
|
||||
- '@swc-node/register'
|
||||
- '@swc/core'
|
||||
- debug
|
||||
- supports-color
|
||||
|
||||
'@phenomnomnominal/tsquery@5.0.1(typescript@5.9.3)':
|
||||
dependencies:
|
||||
@ -7163,7 +7152,7 @@ snapshots:
|
||||
|
||||
'@yarnpkg/parsers@3.0.2':
|
||||
dependencies:
|
||||
js-yaml: 4.3.0
|
||||
js-yaml: 4.1.1
|
||||
tslib: 2.8.1
|
||||
|
||||
'@zkochan/js-yaml@0.0.7':
|
||||
@ -7172,12 +7161,6 @@ snapshots:
|
||||
|
||||
address@1.2.2: {}
|
||||
|
||||
agent-base@6.0.2:
|
||||
dependencies:
|
||||
debug: 4.4.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
ajv@8.20.0:
|
||||
dependencies:
|
||||
fast-deep-equal: 3.1.3
|
||||
@ -7275,15 +7258,13 @@ snapshots:
|
||||
|
||||
asynckit@0.4.0: {}
|
||||
|
||||
axios@1.18.0:
|
||||
axios@1.16.0:
|
||||
dependencies:
|
||||
follow-redirects: 1.16.0
|
||||
form-data: 4.0.6
|
||||
https-proxy-agent: 5.0.1
|
||||
proxy-from-env: 2.1.0
|
||||
transitivePeerDependencies:
|
||||
- debug
|
||||
- supports-color
|
||||
|
||||
babel-plugin-const-enum@1.2.0(@babel/core@7.29.6):
|
||||
dependencies:
|
||||
@ -7355,7 +7336,7 @@ snapshots:
|
||||
inherits: 2.0.4
|
||||
readable-stream: 3.6.2
|
||||
|
||||
brace-expansion@2.1.2:
|
||||
brace-expansion@2.1.0:
|
||||
dependencies:
|
||||
balanced-match: 1.0.2
|
||||
|
||||
@ -7882,7 +7863,7 @@ snapshots:
|
||||
|
||||
front-matter@4.0.2:
|
||||
dependencies:
|
||||
js-yaml: 4.3.0
|
||||
js-yaml: 4.1.1
|
||||
|
||||
fs-constants@1.0.0: {}
|
||||
|
||||
@ -8073,13 +8054,6 @@ snapshots:
|
||||
|
||||
html-void-elements@3.0.0: {}
|
||||
|
||||
https-proxy-agent@5.0.1:
|
||||
dependencies:
|
||||
agent-base: 6.0.2
|
||||
debug: 4.4.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
iconv-lite@0.6.3:
|
||||
dependencies:
|
||||
safer-buffer: 2.1.2
|
||||
@ -8155,7 +8129,7 @@ snapshots:
|
||||
|
||||
js-tokens@9.0.1: {}
|
||||
|
||||
js-yaml@4.3.0:
|
||||
js-yaml@4.1.1:
|
||||
dependencies:
|
||||
argparse: 2.0.1
|
||||
|
||||
@ -8703,7 +8677,7 @@ snapshots:
|
||||
|
||||
minimatch@5.1.9:
|
||||
dependencies:
|
||||
brace-expansion: 2.1.2
|
||||
brace-expansion: 2.1.0
|
||||
|
||||
minimatch@9.0.7:
|
||||
dependencies:
|
||||
@ -8738,7 +8712,7 @@ snapshots:
|
||||
'@yarnpkg/lockfile': 1.1.0
|
||||
'@yarnpkg/parsers': 3.0.2
|
||||
'@zkochan/js-yaml': 0.0.7
|
||||
axios: 1.18.0
|
||||
axios: 1.16.0
|
||||
chalk: 4.1.2
|
||||
cli-cursor: 3.1.0
|
||||
cli-spinners: 2.6.1
|
||||
@ -8782,7 +8756,6 @@ snapshots:
|
||||
'@nx/nx-win32-x64-msvc': 21.6.11
|
||||
transitivePeerDependencies:
|
||||
- debug
|
||||
- supports-color
|
||||
|
||||
once@1.4.0:
|
||||
dependencies:
|
||||
|
||||
@ -8,12 +8,10 @@ allowBuilds:
|
||||
|
||||
overrides:
|
||||
'@babel/core@<=7.29.0': 7.29.6
|
||||
'axios@<1.18.0': 1.18.0
|
||||
'brace-expansion@>=2.0.0 <2.1.2': 2.1.2
|
||||
'dompurify@<=3.4.10': 3.4.11
|
||||
'esbuild@>=0.27.3 <0.28.1': 0.28.1
|
||||
'form-data@>=4.0.0 <4.0.6': 4.0.6
|
||||
'js-yaml@<4.3.0': 4.3.0
|
||||
'js-yaml@<3.15.0': 4.1.1
|
||||
'mermaid@>=11.0.0-alpha.1 <=11.14.0': 11.15.0
|
||||
'minimatch@>=9.0.0 <9.0.7': 9.0.7
|
||||
'@nx/js>picomatch': 4.0.5
|
||||
|
||||
@ -188,15 +188,14 @@ tar -xzf "$PREFIX/downloads/trivy_${TRIVY_VERSION}_Linux-64bit.tar.gz" -C "$PREF
|
||||
chmod 0755 "$PREFIX/bin/trivy"
|
||||
GOBIN="$PREFIX/bin" GOPROXY="${GO_MODULE_PROXY:-https://goproxy.cn,direct}" \
|
||||
"$PREFIX/toolchains/go/bin/go" install golang.org/x/vuln/cmd/govulncheck@v1.6.0
|
||||
chown -R root:root "$PREFIX"
|
||||
chmod -R go-w "$PREFIX"
|
||||
|
||||
require_free_space "$MIN_FREE_GIB" "runner image pull"
|
||||
docker pull "$RUNNER_IMAGE_SOURCE"
|
||||
runner_runtime_image=$(docker image inspect --format '{{.Id}}' "$RUNNER_IMAGE_SOURCE")
|
||||
printf '%s\n' "$runner_runtime_image" | grep -Eq '^sha256:[0-9a-f]{64}$' || \
|
||||
fail "pinned runner image did not resolve to an immutable local image ID"
|
||||
chown -R root:root "$PREFIX"
|
||||
chmod -R go-w "$PREFIX"
|
||||
|
||||
require_free_space "$MIN_POST_INSTALL_FREE_GIB" "completed runner image pull"
|
||||
docker volume create "$RUNNER_VOLUME" >/dev/null
|
||||
|
||||
|
||||
@ -117,11 +117,6 @@ for quality_workflow in "$workflow" "$release_workflow"; do
|
||||
grep -Fq './tests/ci/semver-test.sh' "$quality_workflow"
|
||||
grep -Fq 'docker-compose version' "$quality_workflow"
|
||||
grep -Fq 'docker-compose -f docker-compose.yml config --quiet' "$quality_workflow"
|
||||
grep -Fq 'services:' "$quality_workflow"
|
||||
grep -Fq 'image: docker.io/library/postgres:16-alpine@sha256:57c72fd2a128e416c7fcc499958864df5301e940bca0a56f58fddf30ffc07777' "$quality_workflow"
|
||||
grep -Fq 'POSTGRES_HOST_AUTH_METHOD: trust' "$quality_workflow"
|
||||
grep -Fq 'AI_GATEWAY_TEST_DATABASE_URL: postgresql://easyai_test@postgres:5432/easyai_gateway_test?sslmode=disable' "$quality_workflow"
|
||||
grep -Fq 'run: go run ./cmd/migrate' "$quality_workflow"
|
||||
if grep -Fq 'docker compose' "$quality_workflow"; then
|
||||
echo 'job container must use the read-only standalone Compose binary' >&2
|
||||
exit 1
|
||||
|
||||
Loading…
Reference in New Issue
Block a user