fix gateway loopback validation chains
This commit is contained in:
@@ -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, °radePolicySet)
|
||||
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, °radedPlatform)
|
||||
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, °radeSuccessPlatform)
|
||||
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, °radeTask)
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)))
|
||||
|
||||
Reference in New Issue
Block a user