fix gateway loopback validation chains

This commit is contained in:
2026-05-11 08:48:02 +08:00
parent ff666b1ece
commit ca7e76e815
42 changed files with 1641 additions and 129 deletions
@@ -6,6 +6,7 @@ import (
"encoding/json"
"io"
"log/slog"
"math"
"net/http"
"net/http/httptest"
"os"
@@ -123,8 +124,43 @@ func TestCoreLocalFlow(t *testing.T) {
if _, err := testPool.Exec(ctx, `UPDATE gateway_users SET roles = '["admin"]'::jsonb WHERE username = $1`, username); err != nil {
t.Fatalf("promote smoke user: %v", err)
}
var smokeGatewayUserID string
if err := testPool.QueryRow(ctx, `SELECT id::text FROM gateway_users WHERE username = $1`, username).Scan(&smokeGatewayUserID); err != nil {
t.Fatalf("read smoke gateway user id: %v", err)
}
doJSON(t, server.URL, http.MethodGet, "/api/admin/models", apiKeyResponse.Secret, nil, http.StatusForbidden, nil)
var chatOnlyAPIKeyResponse struct {
Secret string `json:"secret"`
APIKey struct {
ID string `json:"id"`
} `json:"apiKey"`
}
doJSON(t, server.URL, http.MethodPost, "/api/v1/api-keys", loginResponse.AccessToken, map[string]any{
"name": "chat only key",
"scopes": []string{"chat"},
}, http.StatusCreated, &chatOnlyAPIKeyResponse)
var taskCountBefore int
if err := testPool.QueryRow(ctx, `SELECT count(*) FROM gateway_tasks`).Scan(&taskCountBefore); err != nil {
t.Fatalf("count tasks before scoped request: %v", err)
}
doJSON(t, server.URL, http.MethodPost, "/api/v1/images/generations", chatOnlyAPIKeyResponse.Secret, map[string]any{
"model": "gpt-image-1",
"prompt": "scope should block this",
}, http.StatusForbidden, nil)
doJSON(t, server.URL, http.MethodPost, "/api/v1/pricing/estimate", chatOnlyAPIKeyResponse.Secret, map[string]any{
"kind": "images.generations",
"model": "gpt-image-1",
"prompt": "scope should block this estimate",
}, http.StatusForbidden, nil)
var taskCountAfter int
if err := testPool.QueryRow(ctx, `SELECT count(*) FROM gateway_tasks`).Scan(&taskCountAfter); err != nil {
t.Fatalf("count tasks after scoped request: %v", err)
}
if taskCountAfter != taskCountBefore {
t.Fatalf("scoped API key rejection should happen before task creation, before=%d after=%d", taskCountBefore, taskCountAfter)
}
inviteCode := "INVITE-" + suffixText
if _, err := testPool.Exec(ctx, `
INSERT INTO gateway_invitations (invite_code, max_uses, metadata)
@@ -322,6 +358,317 @@ VALUES ($1, 5, '{"purpose":"core-flow"}'::jsonb)`, inviteCode); err != nil {
t.Fatalf("unexpected image edit task: %+v", imageEditResponse.Task)
}
var gptImageModelTypesRaw []byte
if err := testPool.QueryRow(ctx, `
SELECT model_type
FROM platform_models
WHERE model_name = 'gpt-image-1'
AND model_type @> '["image_generate","image_edit"]'::jsonb
LIMIT 1`).Scan(&gptImageModelTypesRaw); err != nil {
t.Fatalf("gpt-image-1 platform model should store both image model types: %v", err)
}
var gptImageModelTypes []string
if err := json.Unmarshal(gptImageModelTypesRaw, &gptImageModelTypes); err != nil {
t.Fatalf("decode gpt-image-1 model_type: %v raw=%s", err, string(gptImageModelTypesRaw))
}
if !stringSliceContains(gptImageModelTypes, "image_generate") || !stringSliceContains(gptImageModelTypes, "image_edit") {
t.Fatalf("gpt-image-1 model_type should include generation and edit types: %+v", gptImageModelTypes)
}
deniedModel := "permission-deny-smoke-" + suffixText
var deniedPlatformModel struct {
ID string `json:"id"`
}
doJSON(t, server.URL, http.MethodPost, "/api/admin/platforms/"+platform.ID+"/models", loginResponse.AccessToken, map[string]any{
"canonicalModelKey": "openai:gpt-4o-mini",
"modelName": deniedModel,
"modelAlias": deniedModel,
"modelType": []string{"text_generate"},
"displayName": "Permission Deny Smoke",
}, http.StatusCreated, &deniedPlatformModel)
doJSON(t, server.URL, http.MethodPost, "/api/admin/access-rules", loginResponse.AccessToken, map[string]any{
"subjectType": "api_key",
"subjectId": chatOnlyAPIKeyResponse.APIKey.ID,
"resourceType": "platform_model",
"resourceId": deniedPlatformModel.ID,
"effect": "deny",
"priority": 10,
"minPermissionLevel": 0,
"status": "active",
}, http.StatusCreated, nil)
var deniedTask struct {
Task struct {
Status string `json:"status"`
ErrorCode string `json:"errorCode"`
} `json:"task"`
}
doJSON(t, server.URL, http.MethodPost, "/api/v1/chat/completions", chatOnlyAPIKeyResponse.Secret, map[string]any{
"model": deniedModel,
"runMode": "simulation",
"simulation": true,
"simulationDurationMs": 5,
"messages": []map[string]any{{"role": "user", "content": "permission deny"}},
}, http.StatusAccepted, &deniedTask)
if deniedTask.Task.Status != "failed" || deniedTask.Task.ErrorCode != "no_model_candidate" {
t.Fatalf("deny access rule should hide denied model from runtime candidates: %+v", deniedTask.Task)
}
var restrictedModels struct {
Items []struct {
ID string `json:"id"`
ModelName string `json:"modelName"`
} `json:"items"`
}
doJSON(t, server.URL, http.MethodGet, "/api/v1/playground/models", chatOnlyAPIKeyResponse.Secret, nil, http.StatusOK, &restrictedModels)
if modelListContains(restrictedModels.Items, deniedPlatformModel.ID) {
t.Fatalf("deny access rule should hide denied model from playable list: %+v", restrictedModels.Items)
}
controlledModel := "permission-allow-smoke-" + suffixText
var controlledPlatformModel struct {
ID string `json:"id"`
}
doJSON(t, server.URL, http.MethodPost, "/api/admin/platforms/"+platform.ID+"/models", loginResponse.AccessToken, map[string]any{
"canonicalModelKey": "openai:gpt-4o-mini",
"modelName": controlledModel,
"modelAlias": controlledModel,
"modelType": []string{"text_generate"},
"displayName": "Permission Allow Smoke",
}, http.StatusCreated, &controlledPlatformModel)
doJSON(t, server.URL, http.MethodPost, "/api/admin/access-rules", loginResponse.AccessToken, map[string]any{
"subjectType": "api_key",
"subjectId": apiKeyResponse.APIKey.ID,
"resourceType": "platform_model",
"resourceId": controlledPlatformModel.ID,
"effect": "allow",
"priority": 10,
"minPermissionLevel": 0,
"status": "active",
}, http.StatusCreated, nil)
var blockedControlledTask struct {
Task struct {
Status string `json:"status"`
ErrorCode string `json:"errorCode"`
} `json:"task"`
}
doJSON(t, server.URL, http.MethodPost, "/api/v1/chat/completions", chatOnlyAPIKeyResponse.Secret, map[string]any{
"model": controlledModel,
"runMode": "simulation",
"simulation": true,
"simulationDurationMs": 5,
"messages": []map[string]any{{"role": "user", "content": "allow should block other keys"}},
}, http.StatusAccepted, &blockedControlledTask)
if blockedControlledTask.Task.Status != "failed" || blockedControlledTask.Task.ErrorCode != "no_model_candidate" {
t.Fatalf("allow access rule should make the resource unavailable to unmatched subjects: %+v", blockedControlledTask.Task)
}
doJSON(t, server.URL, http.MethodPost, "/api/admin/access-rules", loginResponse.AccessToken, map[string]any{
"subjectType": "api_key",
"subjectId": chatOnlyAPIKeyResponse.APIKey.ID,
"resourceType": "platform_model",
"resourceId": controlledPlatformModel.ID,
"effect": "allow",
"priority": 10,
"minPermissionLevel": 0,
"status": "active",
}, http.StatusCreated, nil)
var allowedControlledTask struct {
Task struct {
Status string `json:"status"`
} `json:"task"`
}
doJSON(t, server.URL, http.MethodPost, "/api/v1/chat/completions", chatOnlyAPIKeyResponse.Secret, map[string]any{
"model": controlledModel,
"runMode": "simulation",
"simulation": true,
"simulationDurationMs": 5,
"messages": []map[string]any{{"role": "user", "content": "allow should pass"}},
}, http.StatusAccepted, &allowedControlledTask)
if allowedControlledTask.Task.Status != "succeeded" {
t.Fatalf("matching allow access rule should make the controlled model usable: %+v", allowedControlledTask.Task)
}
var customPricingRuleSet struct {
ID string `json:"id"`
}
doJSON(t, server.URL, http.MethodPost, "/api/admin/pricing/rule-sets", loginResponse.AccessToken, map[string]any{
"ruleSetKey": "smoke-pricing-" + suffixText,
"name": "Smoke Pricing",
"currency": "resource",
"rules": []map[string]any{
{"ruleKey": "text_input", "displayName": "Text Input", "resourceType": "text_input", "unit": "1k_tokens", "basePrice": 1},
{"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},
{"ruleKey": "video", "displayName": "Video", "resourceType": "video", "unit": "video", "basePrice": 13},
},
}, http.StatusCreated, &customPricingRuleSet)
pricingModel := "pricing-smoke-" + suffixText
var pricingPlatformModel map[string]any
doJSON(t, server.URL, http.MethodPost, "/api/admin/platforms/"+platform.ID+"/models", loginResponse.AccessToken, map[string]any{
"canonicalModelKey": "openai:gpt-4o-mini",
"modelName": pricingModel,
"modelAlias": pricingModel,
"modelType": []string{"text_generate"},
"displayName": "Pricing Smoke",
"pricingRuleSetId": customPricingRuleSet.ID,
}, http.StatusCreated, &pricingPlatformModel)
if _, err := testPool.Exec(ctx, `
UPDATE gateway_wallet_accounts
SET balance = 100, total_spent = 0, updated_at = now()
WHERE gateway_user_id = $1::uuid
AND currency = 'resource'`, smokeGatewayUserID); err != nil {
t.Fatalf("seed wallet balance: %v", err)
}
var walletBalanceBefore float64
if err := testPool.QueryRow(ctx, `
SELECT balance::float8
FROM gateway_wallet_accounts
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 pricingTask struct {
Task struct {
ID string `json:"id"`
Status string `json:"status"`
BillingSummary map[string]any `json:"billingSummary"`
FinalChargeAmount float64 `json:"finalChargeAmount"`
} `json:"task"`
}
doJSON(t, server.URL, http.MethodPost, "/api/v1/chat/completions", apiKeyResponse.Secret, map[string]any{
"model": pricingModel,
"runMode": "simulation",
"simulation": true,
"simulationDurationMs": 5,
"messages": []map[string]any{{"role": "user", "content": "priced ping"}},
}, http.StatusAccepted, &pricingTask)
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)
}
var walletBalanceAfter float64
var walletSpentAfter float64
if err := testPool.QueryRow(ctx, `
SELECT balance::float8, total_spent::float8
FROM gateway_wallet_accounts
WHERE gateway_user_id = $1::uuid
AND currency = 'resource'`, smokeGatewayUserID).Scan(&walletBalanceAfter, &walletSpentAfter); err != nil {
t.Fatalf("read wallet balance after pricing task: %v", err)
}
if !floatNear(walletBalanceAfter, walletBalanceBefore-pricingTask.Task.FinalChargeAmount) || !floatNear(walletSpentAfter, pricingTask.Task.FinalChargeAmount) {
t.Fatalf("task billing should debit wallet balance and spent totals, before=%f after=%f spent=%f task=%+v", walletBalanceBefore, walletBalanceAfter, walletSpentAfter, pricingTask.Task)
}
var walletTransactionAmount float64
if err := testPool.QueryRow(ctx, `
SELECT amount::float8
FROM gateway_wallet_transactions
WHERE reference_type = 'gateway_task'
AND reference_id = $1
AND transaction_type = 'task_billing'`, pricingTask.Task.ID).Scan(&walletTransactionAmount); err != nil {
t.Fatalf("read task billing wallet transaction: %v", err)
}
if !floatNear(walletTransactionAmount, pricingTask.Task.FinalChargeAmount) {
t.Fatalf("task billing transaction amount=%f want=%f", walletTransactionAmount, pricingTask.Task.FinalChargeAmount)
}
rateLimitedModel := "rate-limit-smoke-" + suffixText
var rateLimitPolicySet struct {
ID string `json:"id"`
}
doJSON(t, server.URL, http.MethodPost, "/api/admin/runtime/policy-sets", loginResponse.AccessToken, map[string]any{
"policyKey": "smoke-rate-limit-" + suffixText,
"name": "Smoke Rate Limit",
"retryPolicy": map[string]any{
"enabled": false,
"maxAttempts": 1,
},
"rateLimitPolicy": map[string]any{
"rules": []map[string]any{{"metric": "rpm", "limit": 1, "windowSeconds": 60}},
},
}, http.StatusCreated, &rateLimitPolicySet)
var rateLimitPlatformModel map[string]any
doJSON(t, server.URL, http.MethodPost, "/api/admin/platforms/"+platform.ID+"/models", loginResponse.AccessToken, map[string]any{
"canonicalModelKey": "openai:gpt-4o-mini",
"modelName": rateLimitedModel,
"modelAlias": rateLimitedModel,
"modelType": []string{"text_generate"},
"displayName": "Rate Limit Smoke",
"runtimePolicySetId": rateLimitPolicySet.ID,
"runtimePolicyOverride": map[string]any{},
}, http.StatusCreated, &rateLimitPlatformModel)
var rateLimitTaskOne struct {
Task struct {
Status string `json:"status"`
} `json:"task"`
}
doJSON(t, server.URL, http.MethodPost, "/api/v1/chat/completions", apiKeyResponse.Secret, map[string]any{
"model": rateLimitedModel,
"runMode": "simulation",
"simulation": true,
"simulationDurationMs": 5,
"messages": []map[string]any{{"role": "user", "content": "first"}},
}, http.StatusAccepted, &rateLimitTaskOne)
if rateLimitTaskOne.Task.Status != "succeeded" {
t.Fatalf("first rate-limited task should succeed: %+v", rateLimitTaskOne.Task)
}
var rateLimitTaskTwo struct {
Task struct {
Status string `json:"status"`
ErrorCode string `json:"errorCode"`
} `json:"task"`
}
doJSON(t, server.URL, http.MethodPost, "/api/v1/chat/completions", apiKeyResponse.Secret, map[string]any{
"model": rateLimitedModel,
"runMode": "simulation",
"simulation": true,
"simulationDurationMs": 5,
"messages": []map[string]any{{"role": "user", "content": "second"}},
}, http.StatusAccepted, &rateLimitTaskTwo)
if rateLimitTaskTwo.Task.Status != "failed" || rateLimitTaskTwo.Task.ErrorCode != "rate_limit" {
t.Fatalf("runtime policy rate limit should fail second task with rate_limit: %+v", rateLimitTaskTwo.Task)
}
videoRouteModel := "video-route-smoke-" + suffixText
var videoRoutePlatformModel map[string]any
doJSON(t, server.URL, http.MethodPost, "/api/admin/platforms/"+platform.ID+"/models", loginResponse.AccessToken, map[string]any{
"canonicalModelKey": "openai:gpt-4o-mini",
"modelName": videoRouteModel,
"modelAlias": videoRouteModel,
"modelType": []string{"video_generate", "image_to_video"},
"displayName": "Video Route Smoke",
}, http.StatusCreated, &videoRoutePlatformModel)
var textToVideoTask struct {
Task struct {
Status string `json:"status"`
ModelType string `json:"modelType"`
} `json:"task"`
}
doJSON(t, server.URL, http.MethodPost, "/api/v1/videos/generations", apiKeyResponse.Secret, map[string]any{
"model": videoRouteModel,
"runMode": "simulation",
"simulation": true,
"simulationDurationMs": 5,
"prompt": "text to video route",
}, http.StatusAccepted, &textToVideoTask)
if textToVideoTask.Task.Status != "succeeded" || textToVideoTask.Task.ModelType != "video_generate" {
t.Fatalf("text-to-video request should use video_generate model_type: %+v", textToVideoTask.Task)
}
var imageToVideoTask struct {
Task struct {
Status string `json:"status"`
ModelType string `json:"modelType"`
} `json:"task"`
}
doJSON(t, server.URL, http.MethodPost, "/api/v1/videos/generations", apiKeyResponse.Secret, map[string]any{
"model": videoRouteModel,
"runMode": "simulation",
"simulation": true,
"simulationDurationMs": 5,
"prompt": "image to video route",
"image": "https://example.com/source.png",
}, http.StatusAccepted, &imageToVideoTask)
if imageToVideoTask.Task.Status != "succeeded" || imageToVideoTask.Task.ModelType != "image_to_video" {
t.Fatalf("image-to-video request should use image_to_video model_type: %+v", imageToVideoTask.Task)
}
failoverModel := "phase1-failover-" + suffixText
var failedPlatform struct {
ID string `json:"id"`
@@ -353,7 +700,7 @@ VALUES ($1, 5, '{"purpose":"core-flow"}'::jsonb)`, inviteCode); err != nil {
"canonicalModelKey": "openai:gpt-4o-mini",
"modelName": failoverModel,
"modelAlias": failoverModel,
"modelType": "chat",
"modelType": []string{"text_generate"},
"displayName": "Failover Smoke",
"retryPolicy": map[string]any{"enabled": true, "maxAttempts": 2},
}, http.StatusCreated, &platformModel)
@@ -376,6 +723,142 @@ VALUES ($1, 5, '{"purpose":"core-flow"}'::jsonb)`, inviteCode); err != nil {
t.Fatalf("failover task should succeed through second client: %+v", failoverTask.Task)
}
var degradePolicySet struct {
ID string `json:"id"`
}
doJSON(t, server.URL, http.MethodPost, "/api/admin/runtime/policy-sets", loginResponse.AccessToken, map[string]any{
"policyKey": "smoke-degrade-" + suffixText,
"name": "Smoke Degrade",
"degradePolicy": map[string]any{
"enabled": true,
"keywords": []string{"rate_limit"},
"cooldownSeconds": 600,
},
}, http.StatusCreated, &degradePolicySet)
degradeModel := "degrade-smoke-" + suffixText
var degradedPlatform struct {
ID string `json:"id"`
}
doJSON(t, server.URL, http.MethodPost, "/api/admin/platforms", loginResponse.AccessToken, map[string]any{
"provider": "openai",
"platformKey": "openai-degrade-" + suffixText,
"name": "OpenAI Degrade Failure",
"baseUrl": "https://api.openai.com/v1",
"authType": "bearer",
"credentials": map[string]any{"mode": "simulation", "simulationFailure": "rate_limit"},
"priority": 30,
}, http.StatusCreated, &degradedPlatform)
var degradeSuccessPlatform struct {
ID string `json:"id"`
}
doJSON(t, server.URL, http.MethodPost, "/api/admin/platforms", loginResponse.AccessToken, map[string]any{
"provider": "openai",
"platformKey": "openai-degrade-success-" + suffixText,
"name": "OpenAI Degrade Success",
"baseUrl": "https://api.openai.com/v1",
"authType": "bearer",
"credentials": map[string]any{"mode": "simulation"},
"priority": 40,
}, http.StatusCreated, &degradeSuccessPlatform)
for _, item := range []struct {
platformID string
runtimePolicySetID string
}{
{platformID: degradedPlatform.ID, runtimePolicySetID: degradePolicySet.ID},
{platformID: degradeSuccessPlatform.ID},
} {
var platformModel map[string]any
doJSON(t, server.URL, http.MethodPost, "/api/admin/platforms/"+item.platformID+"/models", loginResponse.AccessToken, map[string]any{
"canonicalModelKey": "openai:gpt-4o-mini",
"modelName": degradeModel,
"modelAlias": degradeModel,
"modelType": []string{"text_generate"},
"displayName": "Degrade Smoke",
"runtimePolicySetId": item.runtimePolicySetID,
}, http.StatusCreated, &platformModel)
}
var degradeTask struct {
Task struct {
ID string `json:"id"`
Status string `json:"status"`
} `json:"task"`
}
doJSON(t, server.URL, http.MethodPost, "/api/v1/chat/completions", apiKeyResponse.Secret, map[string]any{
"model": degradeModel,
"runMode": "simulation",
"simulation": true,
"simulationDurationMs": 5,
"messages": []map[string]any{{"role": "user", "content": "degrade please"}},
}, http.StatusAccepted, &degradeTask)
if degradeTask.Task.Status != "succeeded" {
t.Fatalf("degrade task should fail over after cooling down failed platform: %+v", degradeTask.Task)
}
var cooledDown bool
if err := testPool.QueryRow(ctx, `SELECT COALESCE(cooldown_until > now(), false) FROM integration_platforms WHERE id = $1::uuid`, degradedPlatform.ID).Scan(&cooledDown); err != nil {
t.Fatalf("read degraded platform cooldown: %v", err)
}
if !cooledDown {
t.Fatal("degrade policy should set platform cooldown_until")
}
var autoDisablePolicySet struct {
ID string `json:"id"`
}
doJSON(t, server.URL, http.MethodPost, "/api/admin/runtime/policy-sets", loginResponse.AccessToken, map[string]any{
"policyKey": "smoke-auto-disable-" + suffixText,
"name": "Smoke Auto Disable",
"autoDisablePolicy": map[string]any{
"enabled": true,
"keywords": []string{"invalid_api_key"},
"threshold": 1,
},
}, http.StatusCreated, &autoDisablePolicySet)
autoDisableModel := "auto-disable-smoke-" + suffixText
var invalidKeyPlatform struct {
ID string `json:"id"`
}
doJSON(t, server.URL, http.MethodPost, "/api/admin/platforms", loginResponse.AccessToken, map[string]any{
"provider": "openai",
"platformKey": "openai-invalid-key-" + suffixText,
"name": "OpenAI Invalid Key",
"baseUrl": "https://api.openai.com/v1",
"authType": "bearer",
"credentials": map[string]any{"mode": "simulation", "simulationFailure": "invalid_api_key"},
"priority": 50,
}, http.StatusCreated, &invalidKeyPlatform)
var invalidKeyPlatformModel map[string]any
doJSON(t, server.URL, http.MethodPost, "/api/admin/platforms/"+invalidKeyPlatform.ID+"/models", loginResponse.AccessToken, map[string]any{
"canonicalModelKey": "openai:gpt-4o-mini",
"modelName": autoDisableModel,
"modelAlias": autoDisableModel,
"modelType": []string{"text_generate"},
"displayName": "Auto Disable Smoke",
"runtimePolicySetId": autoDisablePolicySet.ID,
}, http.StatusCreated, &invalidKeyPlatformModel)
var autoDisableTask struct {
Task struct {
Status string `json:"status"`
ErrorCode string `json:"errorCode"`
} `json:"task"`
}
doJSON(t, server.URL, http.MethodPost, "/api/v1/chat/completions", apiKeyResponse.Secret, map[string]any{
"model": autoDisableModel,
"runMode": "simulation",
"simulation": true,
"simulationDurationMs": 5,
"messages": []map[string]any{{"role": "user", "content": "disable please"}},
}, http.StatusAccepted, &autoDisableTask)
if autoDisableTask.Task.Status != "failed" || autoDisableTask.Task.ErrorCode != "invalid_api_key" {
t.Fatalf("auto disable task should fail with invalid_api_key: %+v", autoDisableTask.Task)
}
var invalidKeyPlatformStatus string
if err := testPool.QueryRow(ctx, `SELECT status FROM integration_platforms WHERE id = $1::uuid`, invalidKeyPlatform.ID).Scan(&invalidKeyPlatformStatus); err != nil {
t.Fatalf("read invalid key platform status: %v", err)
}
if invalidKeyPlatformStatus != "disabled" {
t.Fatalf("auto disable policy should disable platform, got %q", invalidKeyPlatformStatus)
}
var taskDetail struct {
ID string `json:"id"`
Status string `json:"status"`
@@ -393,6 +876,21 @@ VALUES ($1, 5, '{"purpose":"core-flow"}'::jsonb)`, inviteCode); err != nil {
if taskDetail.APIKeyName != apiKeyResponse.APIKey.Name || taskDetail.RequestID == "" || taskDetail.Usage["totalTokens"] == nil || taskDetail.FinalChargeAmount <= 0 {
t.Fatalf("task detail should expose enriched record fields: %+v", taskDetail)
}
var taskList struct {
Items []struct {
ID string `json:"id"`
Status string `json:"status"`
APIKeyName string `json:"apiKeyName"`
ModelType string `json:"modelType"`
FinalCharge float64 `json:"finalChargeAmount"`
ErrorCode string `json:"errorCode"`
ErrorMessage string `json:"errorMessage"`
} `json:"items"`
}
doJSON(t, server.URL, http.MethodGet, "/api/v1/tasks?limit=20", loginResponse.AccessToken, nil, http.StatusOK, &taskList)
if !taskListContains(taskList.Items, taskResponse.Task.ID) || !taskListContains(taskList.Items, pricingTask.Task.ID) {
t.Fatalf("task list should include persisted task records, got %+v", taskList.Items)
}
req, err := http.NewRequest(http.MethodGet, server.URL+"/api/v1/tasks/"+taskResponse.Task.ID+"/events", nil)
if err != nil {
@@ -411,6 +909,9 @@ VALUES ($1, 5, '{"purpose":"core-flow"}'::jsonb)`, inviteCode); err != nil {
if !bytes.Contains(body, []byte("task.progress")) {
t.Fatalf("events response should include progress events body=%s", string(body))
}
if !bytes.Contains(body, []byte("task.billing.settled")) {
t.Fatalf("events response should include billing settlement event body=%s", string(body))
}
req, err = http.NewRequest(http.MethodGet, server.URL+"/api/v1/tasks/"+failoverTask.Task.ID+"/events", nil)
if err != nil {
@@ -508,3 +1009,45 @@ func doJSON(t *testing.T, baseURL string, method string, path string, token stri
}
}
}
func stringSliceContains(values []string, target string) bool {
for _, value := range values {
if value == target {
return true
}
}
return false
}
func modelListContains(values []struct {
ID string `json:"id"`
ModelName string `json:"modelName"`
}, target string) bool {
for _, value := range values {
if value.ID == target {
return true
}
}
return false
}
func taskListContains(values []struct {
ID string `json:"id"`
Status string `json:"status"`
APIKeyName string `json:"apiKeyName"`
ModelType string `json:"modelType"`
FinalCharge float64 `json:"finalChargeAmount"`
ErrorCode string `json:"errorCode"`
ErrorMessage string `json:"errorMessage"`
}, target string) bool {
for _, value := range values {
if value.ID == target {
return true
}
}
return false
}
func floatNear(value float64, expected float64) bool {
return math.Abs(value-expected) < 0.000001
}
+63
View File
@@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"time"
@@ -468,6 +469,10 @@ func (s *Server) estimatePricing(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, "model is required")
return
}
if !apiKeyScopeAllowed(user, kind) {
writeError(w, http.StatusForbidden, "api key scope does not allow this capability")
return
}
estimate, err := s.runner.Estimate(r.Context(), kind, model, body, user)
if err != nil {
if errors.Is(err, store.ErrNoModelCandidate) {
@@ -509,6 +514,10 @@ func (s *Server) createTask(kind string, compatible bool) http.Handler {
writeError(w, http.StatusBadRequest, "model is required")
return
}
if !apiKeyScopeAllowed(user, kind) {
writeError(w, http.StatusForbidden, "api key scope does not allow this capability")
return
}
task, err := s.store.CreateTask(r.Context(), store.CreateTaskInput{
Kind: kind,
@@ -567,6 +576,36 @@ func (s *Server) createTask(kind string, compatible bool) http.Handler {
})
}
func apiKeyScopeAllowed(user *auth.User, kind string) bool {
if user == nil || strings.TrimSpace(user.APIKeyID) == "" || len(user.APIKeyScopes) == 0 {
return true
}
required := scopeForTaskKind(kind)
for _, scope := range user.APIKeyScopes {
scope = strings.TrimSpace(strings.ToLower(scope))
if scope == "*" || scope == "all" || scope == required {
return true
}
if required == "chat" && (scope == "text" || scope == "text_generate") {
return true
}
}
return false
}
func scopeForTaskKind(kind string) string {
switch kind {
case "chat.completions", "responses":
return "chat"
case "images.generations", "images.edits":
return "image"
case "videos.generations":
return "video"
default:
return kind
}
}
func statusFromRunError(err error) int {
switch {
case errors.Is(err, store.ErrNoModelCandidate):
@@ -578,6 +617,30 @@ func statusFromRunError(err error) int {
}
}
func (s *Server) listTasks(w http.ResponseWriter, r *http.Request) {
user, ok := auth.UserFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "unauthorized")
return
}
limit := 50
if raw := strings.TrimSpace(r.URL.Query().Get("limit")); raw != "" {
parsed, err := strconv.Atoi(raw)
if err != nil || parsed <= 0 {
writeError(w, http.StatusBadRequest, "invalid limit")
return
}
limit = parsed
}
tasks, err := s.store.ListTasks(r.Context(), user, limit)
if err != nil {
s.logger.Error("list tasks failed", "error", err)
writeError(w, http.StatusInternalServerError, "list tasks failed")
return
}
writeJSON(w, http.StatusOK, map[string]any{"items": tasks})
}
func boolValue(body map[string]any, key string) bool {
value, _ := body[key].(bool)
return value
+1
View File
@@ -101,6 +101,7 @@ func NewServer(cfg config.Config, db *store.Store, logger *slog.Logger) http.Han
mux.Handle("POST /api/v1/images/generations", server.auth.Require(auth.PermissionBasic, server.createTask("images.generations", false)))
mux.Handle("POST /api/v1/images/edits", server.auth.Require(auth.PermissionBasic, server.createTask("images.edits", false)))
mux.Handle("POST /api/v1/videos/generations", server.auth.Require(auth.PermissionBasic, server.createTask("videos.generations", false)))
mux.Handle("GET /api/v1/tasks", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.listTasks)))
mux.Handle("GET /api/v1/tasks/{taskID}", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.getTask)))
mux.Handle("GET /api/v1/tasks/{taskID}/events", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.taskEvents)))
mux.Handle("POST /chat/completions", server.auth.Require(auth.PermissionBasic, server.createTask("chat.completions", true)))