From 126e3eb81d410eb71806fa314eedf22a171b42cd Mon Sep 17 00:00:00 2001 From: "Jakub A. W" Date: Fri, 8 May 2026 16:40:28 +0200 Subject: [PATCH 1/2] fix(admin): avoid encoded slash admin URLs --- README.md | 4 +- cmd/gomodel/docs/docs.go | 260 +++++++++------- docs/openapi.json | 287 +++++++++--------- internal/admin/dashboard/dashboard.go | 16 +- internal/admin/dashboard/dashboard_test.go | 12 + .../dashboard/static/js/modules/aliases.js | 25 +- .../static/js/modules/aliases.test.cjs | 131 ++++++++ .../dashboard/static/js/modules/budgets.js | 34 ++- .../static/js/modules/budgets.test.cjs | 13 +- .../dashboard/static/js/modules/guardrails.js | 11 +- .../static/js/modules/guardrails.test.cjs | 58 ++++ .../js/modules/model-pricing-overrides.js | 13 +- .../modules/model-pricing-overrides.test.cjs | 60 ++++ internal/admin/errors.go | 53 +--- internal/admin/handler_aliases.go | 54 ++-- internal/admin/handler_aliases_test.go | 32 +- internal/admin/handler_budgets.go | 69 ++--- internal/admin/handler_budgets_test.go | 56 ++-- internal/admin/handler_guardrails.go | 24 +- internal/admin/handler_guardrails_test.go | 30 +- internal/admin/handler_model_overrides.go | 65 ++-- .../admin/handler_model_overrides_test.go | 43 ++- .../admin/handler_model_pricing_overrides.go | 67 ++-- .../handler_model_pricing_overrides_test.go | 13 +- internal/admin/handler_test.go | 4 +- internal/admin/routes.go | 20 +- internal/admin/routes_test.go | 20 +- internal/server/http_test.go | 24 +- tests/e2e/budget_test.go | 11 +- tests/e2e/release-e2e-scenarios.md | 39 +-- .../integration/workflows_guardrails_test.go | 8 +- 31 files changed, 942 insertions(+), 614 deletions(-) diff --git a/README.md b/README.md index 6f594ea4..a974d6a4 100644 --- a/README.md +++ b/README.md @@ -230,8 +230,8 @@ docker run --rm -p 8080:8080 --env-file .env gomodel | `/admin/api/v1/models` | GET | List models with provider type | | `/admin/api/v1/models/categories` | GET | List model categories | | `/admin/api/v1/model-overrides` | GET | List model overrides | -| `/admin/api/v1/model-overrides/:selector` | PUT | Create/update model override | -| `/admin/api/v1/model-overrides/:selector` | DELETE | Remove model override | +| `/admin/api/v1/model-overrides` | PUT | Create/update model override | +| `/admin/api/v1/model-overrides` | DELETE | Remove model override | | `/admin/api/v1/auth-keys` | GET | List authentication keys | ### Operations Endpoints diff --git a/cmd/gomodel/docs/docs.go b/cmd/gomodel/docs/docs.go index 3351d910..a4ca2524 100644 --- a/cmd/gomodel/docs/docs.go +++ b/cmd/gomodel/docs/docs.go @@ -223,10 +223,8 @@ const docTemplate = `{ "BearerAuth": [] } ] - } - }, - "/admin/api/v1/budgets/reset": { - "post": { + }, + "put": { "consumes": [ "application/json" ], @@ -236,15 +234,15 @@ const docTemplate = `{ "tags": [ "admin" ], - "summary": "Reset all budget periods", + "summary": "Create or update one budget", "parameters": [ { - "description": "Reset confirmation", - "name": "confirmation", + "description": "Budget key and amount", + "name": "budget", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/admin.resetBudgetsRequest" + "$ref": "#/definitions/admin.upsertBudgetRequest" } } ], @@ -252,7 +250,7 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/admin.resetBudgetsResponse" + "$ref": "#/definitions/admin.budgetListResponse" } }, "400": { @@ -279,10 +277,8 @@ const docTemplate = `{ "BearerAuth": [] } ] - } - }, - "/admin/api/v1/budgets/reset-one": { - "post": { + }, + "delete": { "consumes": [ "application/json" ], @@ -292,7 +288,7 @@ const docTemplate = `{ "tags": [ "admin" ], - "summary": "Reset one budget period", + "summary": "Delete one budget", "parameters": [ { "description": "Budget key", @@ -300,7 +296,7 @@ const docTemplate = `{ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/admin.resetBudgetRequest" + "$ref": "#/definitions/admin.deleteBudgetRequest" } } ], @@ -337,20 +333,40 @@ const docTemplate = `{ ] } }, - "/admin/api/v1/budgets/settings": { - "get": { + "/admin/api/v1/budgets/reset": { + "post": { + "consumes": [ + "application/json" + ], "produces": [ "application/json" ], "tags": [ "admin" ], - "summary": "Get budget reset settings", + "summary": "Reset all budget periods", + "parameters": [ + { + "description": "Reset confirmation", + "name": "confirmation", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/admin.resetBudgetsRequest" + } + } + ], "responses": { "200": { "description": "OK", "schema": { - "$ref": "#/definitions/budget.Settings" + "$ref": "#/definitions/admin.resetBudgetsResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/core.GatewayError" } }, "401": { @@ -371,8 +387,10 @@ const docTemplate = `{ "BearerAuth": [] } ] - }, - "put": { + } + }, + "/admin/api/v1/budgets/reset-one": { + "post": { "consumes": [ "application/json" ], @@ -382,15 +400,15 @@ const docTemplate = `{ "tags": [ "admin" ], - "summary": "Update budget reset settings", + "summary": "Reset one budget period", "parameters": [ { - "description": "Budget reset settings", - "name": "settings", + "description": "Budget key", + "name": "budget", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/admin.updateBudgetSettingsRequest" + "$ref": "#/definitions/admin.resetBudgetRequest" } } ], @@ -398,7 +416,7 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/budget.Settings" + "$ref": "#/definitions/admin.budgetListResponse" } }, "400": { @@ -427,54 +445,20 @@ const docTemplate = `{ ] } }, - "/admin/api/v1/budgets/{user_path}/{period}": { - "put": { - "consumes": [ - "application/json" - ], + "/admin/api/v1/budgets/settings": { + "get": { "produces": [ "application/json" ], "tags": [ "admin" ], - "summary": "Create or update one budget", - "parameters": [ - { - "type": "string", - "description": "URL-encoded budget user path", - "name": "user_path", - "in": "path", - "required": true - }, - { - "type": "string", - "description": "Budget period name or seconds", - "name": "period", - "in": "path", - "required": true - }, - { - "description": "Budget amount", - "name": "budget", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/admin.upsertBudgetRequest" - } - } - ], + "summary": "Get budget reset settings", "responses": { "200": { "description": "OK", "schema": { - "$ref": "#/definitions/admin.budgetListResponse" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/core.GatewayError" + "$ref": "#/definitions/budget.Settings" } }, "401": { @@ -496,35 +480,33 @@ const docTemplate = `{ } ] }, - "delete": { + "put": { + "consumes": [ + "application/json" + ], "produces": [ "application/json" ], "tags": [ "admin" ], - "summary": "Delete one budget", + "summary": "Update budget reset settings", "parameters": [ { - "type": "string", - "description": "URL-encoded budget user path", - "name": "user_path", - "in": "path", - "required": true - }, - { - "type": "string", - "description": "Budget period name or seconds", - "name": "period", - "in": "path", - "required": true + "description": "Budget reset settings", + "name": "settings", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/admin.updateBudgetSettingsRequest" + } } ], "responses": { "200": { "description": "OK", "schema": { - "$ref": "#/definitions/admin.budgetListResponse" + "$ref": "#/definitions/budget.Settings" } }, "400": { @@ -701,9 +683,7 @@ const docTemplate = `{ "BearerAuth": [] } ] - } - }, - "/admin/api/v1/model-overrides/{selector}": { + }, "put": { "consumes": [ "application/json" @@ -717,14 +697,7 @@ const docTemplate = `{ "summary": "Create or update one model access override", "parameters": [ { - "type": "string", - "description": "URL-encoded model selector such as /, openai/, gpt-4o-mini, or openai/gpt-4o-mini", - "name": "selector", - "in": "path", - "required": true - }, - { - "description": "Allowed user paths", + "description": "Model selector and allowed user paths", "name": "override", "in": "body", "required": true, @@ -778,6 +751,9 @@ const docTemplate = `{ ] }, "delete": { + "consumes": [ + "application/json" + ], "produces": [ "application/json" ], @@ -787,11 +763,13 @@ const docTemplate = `{ "summary": "Delete one model access override", "parameters": [ { - "type": "string", - "description": "URL-encoded model selector", - "name": "selector", - "in": "path", - "required": true + "description": "Model selector to remove", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/admin.deleteModelOverrideRequest" + } } ], "responses": { @@ -874,9 +852,7 @@ const docTemplate = `{ "BearerAuth": [] } ] - } - }, - "/admin/api/v1/model-pricing-overrides/{selector}": { + }, "put": { "description": "Stores USD-only pricing for one selector. More precise selectors override broader selectors at runtime.", "consumes": [ @@ -891,14 +867,7 @@ const docTemplate = `{ "summary": "Create or update one model pricing override", "parameters": [ { - "type": "string", - "description": "URL-encoded pricing selector such as /, openai/, gpt-4o-mini, or openai/gpt-4o-mini", - "name": "selector", - "in": "path", - "required": true - }, - { - "description": "Pricing override", + "description": "Pricing selector and override", "name": "override", "in": "body", "required": true, @@ -946,6 +915,9 @@ const docTemplate = `{ ] }, "delete": { + "consumes": [ + "application/json" + ], "produces": [ "application/json" ], @@ -955,11 +927,13 @@ const docTemplate = `{ "summary": "Delete one model pricing override", "parameters": [ { - "type": "string", - "description": "URL-encoded pricing selector", - "name": "selector", - "in": "path", - "required": true + "description": "Pricing selector to remove", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/admin.deleteModelPricingOverrideRequest" + } } ], "responses": { @@ -3652,6 +3626,45 @@ const docTemplate = `{ } } }, + "admin.deleteBudgetRequest": { + "type": "object", + "required": [ + "user_path" + ], + "properties": { + "period": { + "type": "string" + }, + "period_seconds": { + "type": "integer" + }, + "user_path": { + "type": "string" + } + } + }, + "admin.deleteModelOverrideRequest": { + "type": "object", + "required": [ + "selector" + ], + "properties": { + "selector": { + "type": "string" + } + } + }, + "admin.deleteModelPricingOverrideRequest": { + "type": "object", + "required": [ + "selector" + ], + "properties": { + "selector": { + "type": "string" + } + } + }, "admin.modelAccessResponse": { "type": "object", "properties": { @@ -3785,15 +3798,34 @@ const docTemplate = `{ }, "admin.upsertBudgetRequest": { "type": "object", + "required": [ + "amount", + "user_path" + ], "properties": { "amount": { "type": "number" + }, + "period": { + "type": "string" + }, + "period_seconds": { + "type": "integer" + }, + "user_path": { + "type": "string" } } }, "admin.upsertModelOverrideRequest": { "type": "object", + "required": [ + "selector" + ], "properties": { + "selector": { + "type": "string" + }, "user_paths": { "type": "array", "items": { @@ -3805,11 +3837,15 @@ const docTemplate = `{ "admin.upsertModelPricingOverrideRequest": { "type": "object", "required": [ - "pricing" + "pricing", + "selector" ], "properties": { "pricing": { "$ref": "#/definitions/pricingoverrides.Pricing" + }, + "selector": { + "type": "string" } } }, diff --git a/docs/openapi.json b/docs/openapi.json index 3d30dc56..25bbe3da 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -273,23 +273,21 @@ "BearerAuth": [] } ] - } - }, - "/admin/api/v1/budgets/reset": { - "post": { + }, + "put": { "tags": [ "admin" ], - "summary": "Reset all budget periods", + "summary": "Create or update one budget", "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/admin.resetBudgetsRequest" + "$ref": "#/components/schemas/admin.upsertBudgetRequest" } } }, - "description": "Reset confirmation", + "description": "Budget key and amount", "required": true }, "responses": { @@ -298,7 +296,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/admin.resetBudgetsResponse" + "$ref": "#/components/schemas/admin.budgetListResponse" } } } @@ -339,19 +337,17 @@ "BearerAuth": [] } ] - } - }, - "/admin/api/v1/budgets/reset-one": { - "post": { + }, + "delete": { "tags": [ "admin" ], - "summary": "Reset one budget period", + "summary": "Delete one budget", "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/admin.resetBudgetRequest" + "$ref": "#/components/schemas/admin.deleteBudgetRequest" } } }, @@ -407,19 +403,40 @@ ] } }, - "/admin/api/v1/budgets/settings": { - "get": { + "/admin/api/v1/budgets/reset": { + "post": { "tags": [ "admin" ], - "summary": "Get budget reset settings", + "summary": "Reset all budget periods", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/admin.resetBudgetsRequest" + } + } + }, + "description": "Reset confirmation", + "required": true + }, "responses": { "200": { "description": "OK", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/budget.Settings" + "$ref": "#/components/schemas/admin.resetBudgetsResponse" + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/core.GatewayError" } } } @@ -450,21 +467,23 @@ "BearerAuth": [] } ] - }, - "put": { + } + }, + "/admin/api/v1/budgets/reset-one": { + "post": { "tags": [ "admin" ], - "summary": "Update budget reset settings", + "summary": "Reset one budget period", "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/admin.updateBudgetSettingsRequest" + "$ref": "#/components/schemas/admin.resetBudgetRequest" } } }, - "description": "Budget reset settings", + "description": "Budget key", "required": true }, "responses": { @@ -473,7 +492,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/budget.Settings" + "$ref": "#/components/schemas/admin.budgetListResponse" } } } @@ -516,60 +535,19 @@ ] } }, - "/admin/api/v1/budgets/{user_path}/{period}": { - "put": { + "/admin/api/v1/budgets/settings": { + "get": { "tags": [ "admin" ], - "summary": "Create or update one budget", - "parameters": [ - { - "description": "URL-encoded budget user path", - "name": "user_path", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "description": "Budget period name or seconds", - "name": "period", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/admin.upsertBudgetRequest" - } - } - }, - "description": "Budget amount", - "required": true - }, + "summary": "Get budget reset settings", "responses": { "200": { "description": "OK", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/admin.budgetListResponse" - } - } - } - }, - "400": { - "description": "Bad Request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/core.GatewayError" + "$ref": "#/components/schemas/budget.Settings" } } } @@ -601,38 +579,29 @@ } ] }, - "delete": { + "put": { "tags": [ "admin" ], - "summary": "Delete one budget", - "parameters": [ - { - "description": "URL-encoded budget user path", - "name": "user_path", - "in": "path", - "required": true, - "schema": { - "type": "string" + "summary": "Update budget reset settings", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/admin.updateBudgetSettingsRequest" + } } }, - { - "description": "Budget period name or seconds", - "name": "period", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], + "description": "Budget reset settings", + "required": true + }, "responses": { "200": { "description": "OK", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/admin.budgetListResponse" + "$ref": "#/components/schemas/budget.Settings" } } } @@ -864,25 +833,12 @@ "BearerAuth": [] } ] - } - }, - "/admin/api/v1/model-overrides/{selector}": { + }, "put": { "tags": [ "admin" ], "summary": "Create or update one model access override", - "parameters": [ - { - "description": "URL-encoded model selector such as /, openai/, gpt-4o-mini, or openai/gpt-4o-mini", - "name": "selector", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], "requestBody": { "content": { "application/json": { @@ -891,7 +847,7 @@ } } }, - "description": "Allowed user paths", + "description": "Model selector and allowed user paths", "required": true }, "responses": { @@ -967,17 +923,17 @@ "admin" ], "summary": "Delete one model access override", - "parameters": [ - { - "description": "URL-encoded model selector", - "name": "selector", - "in": "path", - "required": true, - "schema": { - "type": "string" + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/admin.deleteModelOverrideRequest" + } } - } - ], + }, + "description": "Model selector to remove", + "required": true + }, "responses": { "204": { "description": "No Content" @@ -1089,26 +1045,13 @@ "BearerAuth": [] } ] - } - }, - "/admin/api/v1/model-pricing-overrides/{selector}": { + }, "put": { "description": "Stores USD-only pricing for one selector. More precise selectors override broader selectors at runtime.", "tags": [ "admin" ], "summary": "Create or update one model pricing override", - "parameters": [ - { - "description": "URL-encoded pricing selector such as /, openai/, gpt-4o-mini, or openai/gpt-4o-mini", - "name": "selector", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], "requestBody": { "content": { "application/json": { @@ -1117,7 +1060,7 @@ } } }, - "description": "Pricing override", + "description": "Pricing selector and override", "required": true }, "responses": { @@ -1183,17 +1126,17 @@ "admin" ], "summary": "Delete one model pricing override", - "parameters": [ - { - "description": "URL-encoded pricing selector", - "name": "selector", - "in": "path", - "required": true, - "schema": { - "type": "string" + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/admin.deleteModelPricingOverrideRequest" + } } - } - ], + }, + "description": "Pricing selector to remove", + "required": true + }, "responses": { "204": { "description": "No Content" @@ -5230,6 +5173,45 @@ } } }, + "admin.deleteBudgetRequest": { + "type": "object", + "required": [ + "user_path" + ], + "properties": { + "period": { + "type": "string" + }, + "period_seconds": { + "type": "integer" + }, + "user_path": { + "type": "string" + } + } + }, + "admin.deleteModelOverrideRequest": { + "type": "object", + "required": [ + "selector" + ], + "properties": { + "selector": { + "type": "string" + } + } + }, + "admin.deleteModelPricingOverrideRequest": { + "type": "object", + "required": [ + "selector" + ], + "properties": { + "selector": { + "type": "string" + } + } + }, "admin.modelAccessResponse": { "type": "object", "properties": { @@ -5366,15 +5348,34 @@ }, "admin.upsertBudgetRequest": { "type": "object", + "required": [ + "amount", + "user_path" + ], "properties": { "amount": { "type": "number" + }, + "period": { + "type": "string" + }, + "period_seconds": { + "type": "integer" + }, + "user_path": { + "type": "string" } } }, "admin.upsertModelOverrideRequest": { "type": "object", + "required": [ + "selector" + ], "properties": { + "selector": { + "type": "string" + }, "user_paths": { "type": "array", "items": { @@ -5388,11 +5389,15 @@ "admin.upsertModelPricingOverrideRequest": { "type": "object", "required": [ - "pricing" + "pricing", + "selector" ], "properties": { "pricing": { "$ref": "#/components/schemas/pricingoverrides.Pricing" + }, + "selector": { + "type": "string" } } }, diff --git a/internal/admin/dashboard/dashboard.go b/internal/admin/dashboard/dashboard.go index 5e3c83d4..8b358144 100644 --- a/internal/admin/dashboard/dashboard.go +++ b/internal/admin/dashboard/dashboard.go @@ -36,7 +36,7 @@ func New() (*Handler, error) { // NewWithBasePath creates a dashboard handler for an app mounted under basePath. func NewWithBasePath(basePath string) (*Handler, error) { basePath = config.NormalizeBasePath(basePath) - assetVersions, err := buildAssetVersions("css/dashboard.css") + assetVersions, err := buildFrontendAssetVersions() if err != nil { return nil, err } @@ -109,6 +109,20 @@ func buildAssetVersions(paths ...string) (map[string]string, error) { return versions, nil } +func buildFrontendAssetVersions() (map[string]string, error) { + paths := []string{} + for _, pattern := range []string{"static/css/*.css", "static/js/*.js", "static/js/modules/*.js"} { + matches, err := fs.Glob(content, pattern) + if err != nil { + return nil, err + } + for _, match := range matches { + paths = append(paths, strings.TrimPrefix(match, "static/")) + } + } + return buildAssetVersions(paths...) +} + func assetURL(basePath, assetPath string, versions map[string]string) string { normalizedPath := strings.TrimLeft(strings.TrimSpace(assetPath), "/") if normalizedPath == "" { diff --git a/internal/admin/dashboard/dashboard_test.go b/internal/admin/dashboard/dashboard_test.go index 2d427fb7..e630c47d 100644 --- a/internal/admin/dashboard/dashboard_test.go +++ b/internal/admin/dashboard/dashboard_test.go @@ -63,6 +63,12 @@ func TestIndex_ReturnsHTML(t *testing.T) { if !regexp.MustCompile(`/admin/static/css/dashboard\.css\?v=[0-9a-f]+`).MatchString(rec.Body.String()) { t.Errorf("expected versioned dashboard CSS link in page HTML") } + if !regexp.MustCompile(`/admin/static/js/dashboard\.js\?v=[0-9a-f]+`).MatchString(rec.Body.String()) { + t.Errorf("expected versioned dashboard JS link in page HTML") + } + if !regexp.MustCompile(`/admin/static/js/modules/aliases\.js\?v=[0-9a-f]+`).MatchString(rec.Body.String()) { + t.Errorf("expected versioned dashboard module JS link in page HTML") + } if !strings.Contains(body, "settings-version-footer") { t.Errorf("expected settings-version-footer element in page HTML") } @@ -94,6 +100,12 @@ func TestIndex_UsesBasePathForGeneratedURLs(t *testing.T) { if !regexp.MustCompile(`/g/admin/static/css/dashboard\.css\?v=[0-9a-f]+`).MatchString(body) { t.Errorf("expected versioned dashboard CSS link to include base path") } + if !regexp.MustCompile(`/g/admin/static/js/dashboard\.js\?v=[0-9a-f]+`).MatchString(body) { + t.Errorf("expected versioned dashboard JS link to include base path") + } + if !regexp.MustCompile(`/g/admin/static/js/modules/aliases\.js\?v=[0-9a-f]+`).MatchString(body) { + t.Errorf("expected versioned dashboard module JS link to include base path") + } if !strings.Contains(body, `href="/g/admin/dashboard/overview"`) { t.Errorf("expected dashboard navigation links to include base path") } diff --git a/internal/admin/dashboard/static/js/modules/aliases.js b/internal/admin/dashboard/static/js/modules/aliases.js index 708ae575..d499dc95 100644 --- a/internal/admin/dashboard/static/js/modules/aliases.js +++ b/internal/admin/dashboard/static/js/modules/aliases.js @@ -701,6 +701,7 @@ this.aliasFormError = ''; const payload = { + name: alias.name, target_model: alias.target_provider ? alias.target_provider + '/' + alias.target_model : alias.target_model, description: String(alias.description || '').trim(), enabled: alias.enabled === false @@ -711,7 +712,7 @@ method: 'PUT', body: JSON.stringify(payload) }); - const res = await fetch('/admin/api/v1/aliases/' + encodeURIComponent(alias.name), request); + const res = await fetch('/admin/api/v1/aliases', request); if (res.status === 503) { this.aliasesAvailable = false; this.aliasError = 'Aliases feature is unavailable.'; @@ -902,6 +903,7 @@ this.aliasSubmitting = true; const payload = { + name, target_model: targetModel, description: String(this.aliasForm.description || '').trim(), enabled: Boolean(this.aliasForm.enabled) @@ -912,7 +914,7 @@ method: 'PUT', body: JSON.stringify(payload) }); - const saveRes = await fetch('/admin/api/v1/aliases/' + encodeURIComponent(name), saveRequest); + const saveRes = await fetch('/admin/api/v1/aliases', saveRequest); if (saveRes.status === 503) { this.aliasesAvailable = false; @@ -932,9 +934,10 @@ if (originalName && originalName !== name) { const deleteRequest = this.adminRequestOptions({ - method: 'DELETE' + method: 'DELETE', + body: JSON.stringify({ name: originalName }) }); - const deleteRes = await fetch('/admin/api/v1/aliases/' + encodeURIComponent(originalName), deleteRequest); + const deleteRes = await fetch('/admin/api/v1/aliases', deleteRequest); if (deleteRes.status !== 404) { const deleteHandled = this.handleFetchResponse(deleteRes, 'previous alias', deleteRequest); if (typeof this.isStaleAuthFetchResult === 'function' && this.isStaleAuthFetchResult(deleteHandled)) { @@ -974,9 +977,10 @@ try { const request = this.adminRequestOptions({ - method: 'DELETE' + method: 'DELETE', + body: JSON.stringify({ name: alias.name }) }); - const res = await fetch('/admin/api/v1/aliases/' + encodeURIComponent(alias.name), request); + const res = await fetch('/admin/api/v1/aliases', request); if (res.status === 503) { this.aliasesAvailable = false; this.aliasError = 'Aliases feature is unavailable.'; @@ -1025,14 +1029,14 @@ this.modelOverrideError = ''; this.modelOverrideNotice = ''; - const payload = { user_paths: userPaths }; + const payload = { selector, user_paths: userPaths }; try { const request = this.adminRequestOptions({ method: 'PUT', body: JSON.stringify(payload) }); - const res = await fetch('/admin/api/v1/model-overrides/' + encodeURIComponent(selector), request); + const res = await fetch('/admin/api/v1/model-overrides', request); if (res.status === 503) { this.modelOverridesAvailable = false; this.modelOverrideError = 'Model overrides feature is unavailable.'; @@ -1076,9 +1080,10 @@ try { const request = this.adminRequestOptions({ - method: 'DELETE' + method: 'DELETE', + body: JSON.stringify({ selector }) }); - const res = await fetch('/admin/api/v1/model-overrides/' + encodeURIComponent(selector), request); + const res = await fetch('/admin/api/v1/model-overrides', request); if (res.status === 503) { this.modelOverridesAvailable = false; this.modelOverrideError = 'Model overrides feature is unavailable.'; diff --git a/internal/admin/dashboard/static/js/modules/aliases.test.cjs b/internal/admin/dashboard/static/js/modules/aliases.test.cjs index d1120ee8..6ff5758e 100644 --- a/internal/admin/dashboard/static/js/modules/aliases.test.cjs +++ b/internal/admin/dashboard/static/js/modules/aliases.test.cjs @@ -70,6 +70,137 @@ test('qualifiedModelName prefers selector when available', () => { assert.equal(module.qualifiedModelName(model), 'openrouter/openai/gpt-3.5-turbo'); }); +test('model override mutations send selector in JSON body', async() => { + const requests = []; + const module = createAliasesModule({ + context: { + fetch: async(url, request) => { + requests.push({ url, request }); + return { + ok: true, + status: 200, + json: async() => ({}) + }; + } + }, + window: { + confirm: () => true + } + }); + + Object.assign(module, { + modelOverrideForm: { + selector: 'openrouter/meta-llama/llama-3.1-8b-instruct', + user_paths: '/team/alpha' + }, + modelOverrideFormHasExistingOverride: true, + requestOptions(options) { + return { + ...(options || {}), + headers: {} + }; + }, + handleFetchResponse() { + return true; + }, + fetchModels: async() => {}, + fetchModelOverrides: async() => {} + }); + + await module.submitModelOverrideForm(); + module.modelOverrideForm = { + selector: 'openrouter/meta-llama/llama-3.1-8b-instruct', + user_paths: '/team/alpha' + }; + module.modelOverrideFormHasExistingOverride = true; + await module.deleteModelOverride(); + + assert.equal(requests.length, 2); + assert.equal(requests[0].url, '/admin/api/v1/model-overrides'); + assert.equal(requests[1].url, '/admin/api/v1/model-overrides'); + assert.deepEqual(JSON.parse(requests[0].request.body), { + selector: 'openrouter/meta-llama/llama-3.1-8b-instruct', + user_paths: ['/team/alpha'] + }); + assert.deepEqual(JSON.parse(requests[1].request.body), { + selector: 'openrouter/meta-llama/llama-3.1-8b-instruct' + }); +}); + +test('alias mutations send alias name in JSON body', async() => { + const requests = []; + const module = createAliasesModule({ + context: { + fetch: async(url, request) => { + requests.push({ url, request }); + return { + ok: true, + status: 200, + json: async() => ({}) + }; + } + }, + window: { + confirm: () => true + } + }); + + Object.assign(module, { + aliases: [], + models: [], + requestOptions(options) { + return { + ...(options || {}), + headers: {} + }; + }, + handleFetchResponse() { + return true; + }, + fetchAliases: async() => {} + }); + + await module.toggleAliasEnabled({ + name: 'openai/smart', + target_model: 'gpt-4o', + target_provider: 'openai', + description: '', + enabled: true + }); + module.aliasForm = { + name: 'openai/smart', + target_model: 'openai/gpt-4o', + description: 'smart alias', + enabled: true + }; + module.aliasFormOriginalName = ''; + await module.submitAliasForm(); + await module.deleteAlias({ name: 'openai/smart' }); + + assert.equal(requests.length, 3); + assert.deepEqual(requests.map((request) => request.url), [ + '/admin/api/v1/aliases', + '/admin/api/v1/aliases', + '/admin/api/v1/aliases' + ]); + assert.deepEqual(requests.map((request) => request.request.method), ['PUT', 'PUT', 'DELETE']); + assert.deepEqual(JSON.parse(requests[0].request.body), { + name: 'openai/smart', + target_model: 'openai/gpt-4o', + description: '', + enabled: false + }); + assert.deepEqual(JSON.parse(requests[1].request.body), { + name: 'openai/smart', + target_model: 'openai/gpt-4o', + description: 'smart alias', + enabled: true + }); + assert.deepEqual(JSON.parse(requests[2].request.body), { + name: 'openai/smart' + }); +}); + test('filteredDisplayModelGroups groups rows by provider_name and applies provider-wide overrides', () => { const module = createAliasesModule(); module.models = [ diff --git a/internal/admin/dashboard/static/js/modules/budgets.js b/internal/admin/dashboard/static/js/modules/budgets.js index 0ee5740d..84a18bbc 100644 --- a/internal/admin/dashboard/static/js/modules/budgets.js +++ b/internal/admin/dashboard/static/js/modules/budgets.js @@ -348,12 +348,6 @@ }; }, - budgetAPIPath(userPath, periodSeconds) { - const encodedPath = encodeURIComponent(this.normalizeBudgetUserPath(userPath)); - const encodedPeriod = encodeURIComponent(String(Math.trunc(Number(periodSeconds || 0)))); - return '/admin/api/v1/budgets/' + encodedPath + '/' + encodedPeriod; - }, - openBudgetOverrideDialog(existing, payload) { this.budgetOverrideExistingBudget = existing || null; this.budgetOverridePendingPayload = payload || null; @@ -441,14 +435,22 @@ const request = typeof this.requestOptions === 'function' ? this.requestOptions({ method: 'PUT', - body: JSON.stringify({ amount: payload.amount }) + body: JSON.stringify({ + user_path: payload.user_path, + period_seconds: payload.period_seconds, + amount: payload.amount + }) }) : { method: 'PUT', headers: this.headers(), - body: JSON.stringify({ amount: payload.amount }) + body: JSON.stringify({ + user_path: payload.user_path, + period_seconds: payload.period_seconds, + amount: payload.amount + }) }; - const res = await fetch(this.budgetAPIPath(payload.user_path, payload.period_seconds), request); + const res = await fetch('/admin/api/v1/budgets', request); if (res.status === 503) { this.budgetsAvailable = false; this.budgetFormError = 'Budget management is unavailable.'; @@ -547,13 +549,21 @@ try { const request = typeof this.requestOptions === 'function' ? this.requestOptions({ - method: 'DELETE' + method: 'DELETE', + body: JSON.stringify({ + user_path: item.user_path, + period_seconds: item.period_seconds + }) }) : { method: 'DELETE', - headers: this.headers() + headers: this.headers(), + body: JSON.stringify({ + user_path: item.user_path, + period_seconds: item.period_seconds + }) }; - const res = await fetch(this.budgetAPIPath(item.user_path, item.period_seconds), request); + const res = await fetch('/admin/api/v1/budgets', request); if (res.status === 503) { this.budgetsAvailable = false; this.budgetError = 'Budget management is unavailable.'; diff --git a/internal/admin/dashboard/static/js/modules/budgets.test.cjs b/internal/admin/dashboard/static/js/modules/budgets.test.cjs index 6f9d5408..045a8aad 100644 --- a/internal/admin/dashboard/static/js/modules/budgets.test.cjs +++ b/internal/admin/dashboard/static/js/modules/budgets.test.cjs @@ -209,9 +209,11 @@ test('confirmBudgetOverride saves the pending create after confirmation', async await module.confirmBudgetOverride(); assert.equal(requests.length, 2); - assert.equal(requests[0].url, '/admin/api/v1/budgets/%2Fteam/86400'); + assert.equal(requests[0].url, '/admin/api/v1/budgets'); assert.equal(requests[0].request.method, 'PUT'); assert.equal(requests[0].request.body, JSON.stringify({ + user_path: '/team', + period_seconds: 86400, amount: 12.5 })); assert.equal(requests[1].url, '/admin/api/v1/budgets'); @@ -312,7 +314,7 @@ test('budgetPeriodLabel and class distinguish standard and custom periods', () = assert.equal(module.budgetPeriodDurationLabel({ period_seconds: 1 }), '1 second'); }); -test('deleteBudget uses the selected budget key in the URL and refreshes from the response envelope', async () => { +test('deleteBudget sends the selected budget key in the body and refreshes from the response envelope', async () => { const requests = []; const module = createBudgetsModule({ confirm(message) { @@ -338,9 +340,12 @@ test('deleteBudget uses the selected budget key in the URL and refreshes from th await module.deleteBudget({ user_path: '/team', period_seconds: 86400, period_label: 'daily' }); assert.equal(requests.length, 1); - assert.equal(requests[0].url, '/admin/api/v1/budgets/%2Fteam/86400'); + assert.equal(requests[0].url, '/admin/api/v1/budgets'); assert.equal(requests[0].request.method, 'DELETE'); - assert.equal(requests[0].request.body, undefined); + assert.equal(requests[0].request.body, JSON.stringify({ + user_path: '/team', + period_seconds: 86400 + })); assert.equal(module.budgetDeletingKey, ''); assert.equal(module.budgetNotice, 'Budget deleted.'); assert.equal(JSON.stringify(module.budgets), JSON.stringify([ diff --git a/internal/admin/dashboard/static/js/modules/guardrails.js b/internal/admin/dashboard/static/js/modules/guardrails.js index 8b3aa3d5..1a45f036 100644 --- a/internal/admin/dashboard/static/js/modules/guardrails.js +++ b/internal/admin/dashboard/static/js/modules/guardrails.js @@ -368,6 +368,7 @@ this.guardrailFormSubmitting = true; const payload = { + name, type, description: String(this.guardrailForm.description || '').trim() || undefined, user_path: String(this.guardrailForm.user_path || '').trim() || undefined, @@ -385,7 +386,7 @@ headers: this.headers(), body: JSON.stringify(payload) }; - const res = await fetch('/admin/api/v1/guardrails/' + encodeURIComponent(name), request); + const res = await fetch('/admin/api/v1/guardrails', request); if (res.status === 503) { this.guardrailsAvailable = false; this.guardrailError = 'Guardrails feature is unavailable.'; @@ -446,13 +447,15 @@ try { const request = typeof this.requestOptions === 'function' ? this.requestOptions({ - method: 'DELETE' + method: 'DELETE', + body: JSON.stringify({ name }) }) : { method: 'DELETE', - headers: this.headers() + headers: this.headers(), + body: JSON.stringify({ name }) }; - const res = await fetch('/admin/api/v1/guardrails/' + encodeURIComponent(name), request); + const res = await fetch('/admin/api/v1/guardrails', request); if (res.status === 503) { this.guardrailsAvailable = false; this.guardrailError = 'Guardrails feature is unavailable.'; diff --git a/internal/admin/dashboard/static/js/modules/guardrails.test.cjs b/internal/admin/dashboard/static/js/modules/guardrails.test.cjs index c60834a5..8a213268 100644 --- a/internal/admin/dashboard/static/js/modules/guardrails.test.cjs +++ b/internal/admin/dashboard/static/js/modules/guardrails.test.cjs @@ -265,6 +265,64 @@ test('submitGuardrailForm logs non-auth HTTP failures before surfacing the UI er assert.match(errors[0], /Failed to save guardrail: 400 Bad Request system_prompt content is required/); }); +test('guardrail mutations send guardrail name in JSON body', async () => { + const requests = []; + const module = createGuardrailsModule({ + fetch: async (url, request) => { + requests.push({ url, request }); + return { + status: 200, + statusText: 'OK' + }; + }, + window: { + confirm: () => true + } + }); + + Object.assign(module, { + guardrailForm: { + name: 'privacy/redactor', + type: 'llm_based_altering', + description: '', + user_path: '', + config: { model: 'openai/gpt-4o-mini', roles: ['user'] } + }, + requestOptions(options) { + return { + ...(options || {}), + headers: {} + }; + }, + handleFetchResponse() { + return true; + }, + fetchGuardrails: async () => {}, + fetchWorkflowGuardrails: async () => {} + }); + + await module.submitGuardrailForm(); + await module.deleteGuardrail({ name: 'privacy/redactor' }); + + assert.equal(requests.length, 2); + assert.deepEqual(requests.map((request) => request.url), [ + '/admin/api/v1/guardrails', + '/admin/api/v1/guardrails' + ]); + assert.deepEqual(requests.map((request) => request.request.method), ['PUT', 'DELETE']); + assert.deepEqual(JSON.parse(requests[0].request.body), { + name: 'privacy/redactor', + type: 'llm_based_altering', + config: { + model: 'openai/gpt-4o-mini', + roles: ['user'] + } + }); + assert.deepEqual(JSON.parse(requests[1].request.body), { + name: 'privacy/redactor' + }); +}); + test('guardrail write paths use generation-aware request handling for stale auth responses', async () => { const scenarios = [ { diff --git a/internal/admin/dashboard/static/js/modules/model-pricing-overrides.js b/internal/admin/dashboard/static/js/modules/model-pricing-overrides.js index 184d46cd..bb797c0b 100644 --- a/internal/admin/dashboard/static/js/modules/model-pricing-overrides.js +++ b/internal/admin/dashboard/static/js/modules/model-pricing-overrides.js @@ -483,15 +483,16 @@ this.modelPricingOverrideError = payload.error; return; } + const requestPayload = { selector, ...payload }; this.modelPricingOverrideSubmitting = true; this.modelPricingOverrideError = ''; this.modelPricingOverrideNotice = ''; try { const request = typeof this.adminRequestOptions === 'function' - ? this.adminRequestOptions({ method: 'PUT', body: JSON.stringify(payload) }) - : this.requestOptions({ method: 'PUT', body: JSON.stringify(payload) }); - const res = await fetch('/admin/api/v1/model-pricing-overrides/' + encodeURIComponent(selector), request); + ? this.adminRequestOptions({ method: 'PUT', body: JSON.stringify(requestPayload) }) + : this.requestOptions({ method: 'PUT', body: JSON.stringify(requestPayload) }); + const res = await fetch('/admin/api/v1/model-pricing-overrides', request); if (res.status === 503) { this.modelPricingOverridesAvailable = false; this.modelPricingOverrideError = 'Model pricing overrides feature is unavailable.'; @@ -536,9 +537,9 @@ this.modelPricingOverrideNotice = ''; try { const request = typeof this.adminRequestOptions === 'function' - ? this.adminRequestOptions({ method: 'DELETE' }) - : this.requestOptions({ method: 'DELETE' }); - const res = await fetch('/admin/api/v1/model-pricing-overrides/' + encodeURIComponent(selector), request); + ? this.adminRequestOptions({ method: 'DELETE', body: JSON.stringify({ selector }) }) + : this.requestOptions({ method: 'DELETE', body: JSON.stringify({ selector }) }); + const res = await fetch('/admin/api/v1/model-pricing-overrides', request); if (res.status === 503) { this.modelPricingOverridesAvailable = false; this.modelPricingOverrideError = 'Model pricing overrides feature is unavailable.'; diff --git a/internal/admin/dashboard/static/js/modules/model-pricing-overrides.test.cjs b/internal/admin/dashboard/static/js/modules/model-pricing-overrides.test.cjs index d8019b53..2b2697d8 100644 --- a/internal/admin/dashboard/static/js/modules/model-pricing-overrides.test.cjs +++ b/internal/admin/dashboard/static/js/modules/model-pricing-overrides.test.cjs @@ -122,3 +122,63 @@ test('payload preserves tiered pricing when scalar rows are absent', () => { } })); }); + +test('model pricing override mutations send selector in JSON body', async() => { + const requests = []; + const module = createModule({ + context: { + fetch: async(url, request) => { + requests.push({ url, request }); + return { + ok: true, + status: 200, + json: async() => ({}) + }; + } + }, + window: { + confirm: () => true + } + }); + + Object.assign(module, { + modelPricingOverrideForm: { + selector: 'openrouter/meta-llama/llama-3.1-8b-instruct' + }, + modelPricingOverrideFormHasExistingOverride: true, + modelPricingOverrideRows: [ + { id: '1', field: 'input_per_mtok', value: '1.25' } + ], + adminRequestOptions(options) { + return { + ...(options || {}), + headers: {} + }; + }, + handleFetchResponse() { + return true; + }, + fetchModelPricingOverrides: async() => {}, + syncDisplayModels() {} + }); + + await module.submitModelPricingOverrideForm(); + module.modelPricingOverrideForm = { + selector: 'openrouter/meta-llama/llama-3.1-8b-instruct' + }; + module.modelPricingOverrideFormHasExistingOverride = true; + await module.deleteModelPricingOverride(); + + assert.equal(requests.length, 2); + assert.equal(requests[0].url, '/admin/api/v1/model-pricing-overrides'); + assert.equal(requests[1].url, '/admin/api/v1/model-pricing-overrides'); + assert.deepEqual(JSON.parse(requests[0].request.body), { + selector: 'openrouter/meta-llama/llama-3.1-8b-instruct', + pricing: { + input_per_mtok: 1.25 + } + }); + assert.deepEqual(JSON.parse(requests[1].request.body), { + selector: 'openrouter/meta-llama/llama-3.1-8b-instruct' + }); +}); diff --git a/internal/admin/errors.go b/internal/admin/errors.go index 9a24d486..5beef2f7 100644 --- a/internal/admin/errors.go +++ b/internal/admin/errors.go @@ -4,7 +4,6 @@ import ( "context" "errors" "net/http" - "net/url" "strings" "github.com/labstack/echo/v5" @@ -99,51 +98,7 @@ func deactivateByID( return c.NoContent(http.StatusNoContent) } -func deleteByName( - c *echo.Context, - unavailableErr error, - paramName string, - decode func(string) (string, error), - deleteFunc func(context.Context, string) error, - notFoundErr error, - notFoundMessage string, - writeError func(error) error, -) error { - if unavailableErr != nil { - return handleError(c, unavailableErr) - } - - name, err := decode(c.Param(paramName)) - if err != nil { - return handleError(c, err) - } - - if err := deleteFunc(c.Request().Context(), name); err != nil { - if errors.Is(err, notFoundErr) { - return handleError(c, core.NewNotFoundError(notFoundMessage+name)) - } - return handleError(c, writeError(err)) - } - return c.NoContent(http.StatusNoContent) -} - -func decodeAliasPathName(raw string) (string, error) { - name, err := url.PathUnescape(strings.TrimSpace(raw)) - if err != nil { - return "", core.NewInvalidRequestError("invalid alias name", err) - } - name = strings.TrimSpace(name) - if name == "" { - return "", core.NewInvalidRequestError("alias name is required", nil) - } - return name, nil -} - -func decodeModelOverridePathSelector(raw string) (string, error) { - selector, err := url.PathUnescape(strings.TrimSpace(raw)) - if err != nil { - return "", core.NewInvalidRequestError("invalid model override selector", err) - } +func normalizeModelOverrideSelector(selector string) (string, error) { selector = strings.TrimSpace(selector) if selector == "" { return "", core.NewInvalidRequestError("model override selector is required", nil) @@ -155,11 +110,7 @@ func decodeModelOverridePathSelector(raw string) (string, error) { // IDs and model IDs are short identifiers, never essays. const modelPricingOverrideSelectorMaxLen = 256 -func decodeModelPricingOverridePathSelector(raw string) (string, error) { - selector, err := url.PathUnescape(strings.TrimSpace(raw)) - if err != nil { - return "", core.NewInvalidRequestError("invalid model pricing override selector", err) - } +func normalizeModelPricingOverrideSelector(selector string) (string, error) { selector = strings.TrimSpace(selector) if selector == "" { return "", core.NewInvalidRequestError("model pricing override selector is required", nil) diff --git a/internal/admin/handler_aliases.go b/internal/admin/handler_aliases.go index 1dfd0ce5..cbb79ebd 100644 --- a/internal/admin/handler_aliases.go +++ b/internal/admin/handler_aliases.go @@ -1,8 +1,9 @@ package admin import ( - "context" + "errors" "net/http" + "strings" "github.com/labstack/echo/v5" @@ -11,12 +12,17 @@ import ( ) type upsertAliasRequest struct { + Name string `json:"name" binding:"required"` TargetModel string `json:"target_model"` TargetProvider string `json:"target_provider,omitempty"` Description string `json:"description,omitempty"` Enabled *bool `json:"enabled,omitempty"` } +type deleteAliasRequest struct { + Name string `json:"name" binding:"required"` +} + func (h *Handler) ListAliases(c *echo.Context) error { if h.aliases == nil { return handleError(c, featureUnavailableError("aliases feature is unavailable")) @@ -28,21 +34,20 @@ func (h *Handler) ListAliases(c *echo.Context) error { return c.JSON(http.StatusOK, views) } -// UpsertAlias handles PUT /admin/api/v1/aliases/{name} +// UpsertAlias handles PUT /admin/api/v1/aliases func (h *Handler) UpsertAlias(c *echo.Context) error { if h.aliases == nil { return handleError(c, featureUnavailableError("aliases feature is unavailable")) } - name, err := decodeAliasPathName(c.Param("name")) - if err != nil { - return handleError(c, err) - } - var req upsertAliasRequest if err := c.Bind(&req); err != nil { return handleError(c, core.NewInvalidRequestError("invalid request body: "+err.Error(), err)) } + name := strings.TrimSpace(req.Name) + if name == "" { + return handleError(c, core.NewInvalidRequestError("alias name is required", nil)) + } enabled := true if existing, ok := h.aliases.Get(name); ok && existing != nil { @@ -69,23 +74,26 @@ func (h *Handler) UpsertAlias(c *echo.Context) error { return c.JSON(http.StatusOK, alias) } -// DeleteAlias handles DELETE /admin/api/v1/aliases/{name} +// DeleteAlias handles DELETE /admin/api/v1/aliases func (h *Handler) DeleteAlias(c *echo.Context) error { - var unavailableErr error - var deleteFunc func(context.Context, string) error if h.aliases == nil { - unavailableErr = featureUnavailableError("aliases feature is unavailable") - } else { - deleteFunc = h.aliases.Delete + return handleError(c, featureUnavailableError("aliases feature is unavailable")) + } + + var req deleteAliasRequest + if err := c.Bind(&req); err != nil { + return handleError(c, core.NewInvalidRequestError("invalid request body: "+err.Error(), err)) + } + name := strings.TrimSpace(req.Name) + if name == "" { + return handleError(c, core.NewInvalidRequestError("alias name is required", nil)) + } + + if err := h.aliases.Delete(c.Request().Context(), name); err != nil { + if errors.Is(err, aliases.ErrNotFound) { + return handleError(c, core.NewNotFoundError("alias not found: "+name)) + } + return handleError(c, aliasWriteError(err)) } - return deleteByName( - c, - unavailableErr, - "name", - decodeAliasPathName, - deleteFunc, - aliases.ErrNotFound, - "alias not found: ", - aliasWriteError, - ) + return c.NoContent(http.StatusNoContent) } diff --git a/internal/admin/handler_aliases_test.go b/internal/admin/handler_aliases_test.go index 563a3de0..2bef00cc 100644 --- a/internal/admin/handler_aliases_test.go +++ b/internal/admin/handler_aliases_test.go @@ -182,17 +182,16 @@ func TestAliasesEndpointsReturn503WhenServiceUnavailable(t *testing.T) { listCtx, listRec := newHandlerContext("/admin/api/v1/aliases") assertUnavailable("ListAliases", h.ListAliases(listCtx), listRec) - putReq := httptest.NewRequest(http.MethodPut, "/admin/api/v1/aliases/smart", bytes.NewBufferString(`{"target_model":"gpt-4o"}`)) + putReq := httptest.NewRequest(http.MethodPut, "/admin/api/v1/aliases", bytes.NewBufferString(`{"name":"smart","target_model":"gpt-4o"}`)) putReq.Header.Set("Content-Type", "application/json") putRec := httptest.NewRecorder() putCtx := e.NewContext(putReq, putRec) - putCtx.SetPathValues(echo.PathValues{{Name: "name", Value: "smart"}}) assertUnavailable("UpsertAlias", h.UpsertAlias(putCtx), putRec) - deleteReq := httptest.NewRequest(http.MethodDelete, "/admin/api/v1/aliases/smart", nil) + deleteReq := httptest.NewRequest(http.MethodDelete, "/admin/api/v1/aliases", bytes.NewBufferString(`{"name":"smart"}`)) + deleteReq.Header.Set("Content-Type", "application/json") deleteRec := httptest.NewRecorder() deleteCtx := e.NewContext(deleteReq, deleteRec) - deleteCtx.SetPathValues(echo.PathValues{{Name: "name", Value: "smart"}}) assertUnavailable("DeleteAlias", h.DeleteAlias(deleteCtx), deleteRec) } @@ -200,11 +199,10 @@ func TestUpsertAliasAndDeleteAlias(t *testing.T) { h := newAliasHandler(t) e := echo.New() - putReq := httptest.NewRequest(http.MethodPut, "/admin/api/v1/aliases/smart", bytes.NewBufferString(`{"target_model":"gpt-4o","description":"primary"}`)) + putReq := httptest.NewRequest(http.MethodPut, "/admin/api/v1/aliases", bytes.NewBufferString(`{"name":"smart","target_model":"gpt-4o","description":"primary"}`)) putReq.Header.Set("Content-Type", "application/json") putRec := httptest.NewRecorder() putCtx := e.NewContext(putReq, putRec) - putCtx.SetPathValues(echo.PathValues{{Name: "name", Value: "smart"}}) if err := h.UpsertAlias(putCtx); err != nil { t.Fatalf("UpsertAlias() error = %v", err) @@ -213,10 +211,10 @@ func TestUpsertAliasAndDeleteAlias(t *testing.T) { t.Fatalf("put status = %d, want 200", putRec.Code) } - deleteReq := httptest.NewRequest(http.MethodDelete, "/admin/api/v1/aliases/smart", nil) + deleteReq := httptest.NewRequest(http.MethodDelete, "/admin/api/v1/aliases", bytes.NewBufferString(`{"name":"smart"}`)) + deleteReq.Header.Set("Content-Type", "application/json") deleteRec := httptest.NewRecorder() deleteCtx := e.NewContext(deleteReq, deleteRec) - deleteCtx.SetPathValues(echo.PathValues{{Name: "name", Value: "smart"}}) if err := h.DeleteAlias(deleteCtx); err != nil { t.Fatalf("DeleteAlias() error = %v", err) @@ -226,15 +224,14 @@ func TestUpsertAliasAndDeleteAlias(t *testing.T) { } } -func TestUpsertAliasDecodesQualifiedAliasName(t *testing.T) { +func TestUpsertAliasAcceptsQualifiedAliasName(t *testing.T) { h := newAliasHandler(t) e := echo.New() - req := httptest.NewRequest(http.MethodPut, "/admin/api/v1/aliases/openai%2Fsmart", bytes.NewBufferString(`{"target_model":"gpt-4o"}`)) + req := httptest.NewRequest(http.MethodPut, "/admin/api/v1/aliases", bytes.NewBufferString(`{"name":"openai/smart","target_model":"gpt-4o"}`)) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() c := e.NewContext(req, rec) - c.SetPathValues(echo.PathValues{{Name: "name", Value: "openai%2Fsmart"}}) if err := h.UpsertAlias(c); err != nil { t.Fatalf("UpsertAlias() error = %v", err) @@ -261,11 +258,10 @@ func TestUpsertAliasPreservesEnabledWhenOmitted(t *testing.T) { }) e := echo.New() - req := httptest.NewRequest(http.MethodPut, "/admin/api/v1/aliases/smart", bytes.NewBufferString(`{"target_model":"gpt-4o","description":"after"}`)) + req := httptest.NewRequest(http.MethodPut, "/admin/api/v1/aliases", bytes.NewBufferString(`{"name":"smart","target_model":"gpt-4o","description":"after"}`)) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() c := e.NewContext(req, rec) - c.SetPathValues(echo.PathValues{{Name: "name", Value: "smart"}}) if err := h.UpsertAlias(c); err != nil { t.Fatalf("UpsertAlias() error = %v", err) @@ -292,11 +288,10 @@ func TestUpsertAliasReturns500OnStoreFailure(t *testing.T) { }) e := echo.New() - req := httptest.NewRequest(http.MethodPut, "/admin/api/v1/aliases/smart", bytes.NewBufferString(`{"target_model":"gpt-4o"}`)) + req := httptest.NewRequest(http.MethodPut, "/admin/api/v1/aliases", bytes.NewBufferString(`{"name":"smart","target_model":"gpt-4o"}`)) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() c := e.NewContext(req, rec) - c.SetPathValues(echo.PathValues{{Name: "name", Value: "smart"}}) if err := h.UpsertAlias(c); err != nil { t.Fatalf("UpsertAlias() error = %v", err) @@ -313,11 +308,10 @@ func TestUpsertAliasReturns400OnValidationError(t *testing.T) { h := newAliasHandler(t) e := echo.New() - req := httptest.NewRequest(http.MethodPut, "/admin/api/v1/aliases/smart", bytes.NewBufferString(`{"description":"missing target"}`)) + req := httptest.NewRequest(http.MethodPut, "/admin/api/v1/aliases", bytes.NewBufferString(`{"name":"smart","description":"missing target"}`)) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() c := e.NewContext(req, rec) - c.SetPathValues(echo.PathValues{{Name: "name", Value: "smart"}}) if err := h.UpsertAlias(c); err != nil { t.Fatalf("UpsertAlias() error = %v", err) @@ -336,10 +330,10 @@ func TestDeleteAliasReturns500OnStoreFailure(t *testing.T) { }) e := echo.New() - req := httptest.NewRequest(http.MethodDelete, "/admin/api/v1/aliases/smart", nil) + req := httptest.NewRequest(http.MethodDelete, "/admin/api/v1/aliases", bytes.NewBufferString(`{"name":"smart"}`)) + req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() c := e.NewContext(req, rec) - c.SetPathValues(echo.PathValues{{Name: "name", Value: "smart"}}) if err := h.DeleteAlias(c); err != nil { t.Fatalf("DeleteAlias() error = %v", err) diff --git a/internal/admin/handler_budgets.go b/internal/admin/handler_budgets.go index 444bab5d..9392054c 100644 --- a/internal/admin/handler_budgets.go +++ b/internal/admin/handler_budgets.go @@ -2,9 +2,7 @@ package admin import ( "errors" - "fmt" "net/http" - "net/url" "strconv" "strings" "time" @@ -40,20 +38,18 @@ func (h *Handler) ListBudgets(c *echo.Context) error { }) } -// UpsertBudget handles PUT /admin/api/v1/budgets/{user_path}/{period}. +// UpsertBudget handles PUT /admin/api/v1/budgets. // @Summary Create or update one budget // @Tags admin // @Accept json // @Produce json // @Security BearerAuth -// @Param user_path path string true "URL-encoded budget user path" -// @Param period path string true "Budget period name or seconds" -// @Param budget body upsertBudgetRequest true "Budget amount" +// @Param budget body upsertBudgetRequest true "Budget key and amount" // @Success 200 {object} budgetListResponse // @Failure 400 {object} core.GatewayError // @Failure 401 {object} core.GatewayError // @Failure 503 {object} core.GatewayError -// @Router /admin/api/v1/budgets/{user_path}/{period} [put] +// @Router /admin/api/v1/budgets [put] func (h *Handler) UpsertBudget(c *echo.Context) error { if h.budgets == nil { return handleError(c, featureUnavailableError("budgets feature is unavailable")) @@ -62,7 +58,7 @@ func (h *Handler) UpsertBudget(c *echo.Context) error { if err := c.Bind(&req); err != nil { return handleError(c, core.NewInvalidRequestError("invalid request body: "+err.Error(), err)) } - userPath, periodSeconds, err := budgetRouteKey(c) + userPath, periodSeconds, err := budgetRequestKey(req.UserPath, req.Period, req.PeriodSeconds) if err != nil { return handleError(c, core.NewInvalidRequestError(err.Error(), err)) } @@ -81,23 +77,27 @@ func (h *Handler) UpsertBudget(c *echo.Context) error { return h.ListBudgets(c) } -// DeleteBudget handles DELETE /admin/api/v1/budgets/{user_path}/{period}. +// DeleteBudget handles DELETE /admin/api/v1/budgets. // @Summary Delete one budget // @Tags admin +// @Accept json // @Produce json // @Security BearerAuth -// @Param user_path path string true "URL-encoded budget user path" -// @Param period path string true "Budget period name or seconds" +// @Param budget body deleteBudgetRequest true "Budget key" // @Success 200 {object} budgetListResponse // @Failure 400 {object} core.GatewayError // @Failure 401 {object} core.GatewayError // @Failure 503 {object} core.GatewayError -// @Router /admin/api/v1/budgets/{user_path}/{period} [delete] +// @Router /admin/api/v1/budgets [delete] func (h *Handler) DeleteBudget(c *echo.Context) error { if h.budgets == nil { return handleError(c, featureUnavailableError("budgets feature is unavailable")) } - userPath, periodSeconds, err := budgetRouteKey(c) + var req deleteBudgetRequest + if err := c.Bind(&req); err != nil { + return handleError(c, core.NewInvalidRequestError("invalid request body: "+err.Error(), err)) + } + userPath, periodSeconds, err := budgetRequestKey(req.UserPath, req.Period, req.PeriodSeconds) if err != nil { return handleError(c, core.NewInvalidRequestError(err.Error(), err)) } @@ -241,7 +241,16 @@ type budgetStatusResponse struct { } type upsertBudgetRequest struct { - Amount float64 `json:"amount"` + UserPath string `json:"user_path" binding:"required"` + Period string `json:"period,omitempty"` + PeriodSeconds int64 `json:"period_seconds,omitempty"` + Amount float64 `json:"amount" binding:"required"` +} + +type deleteBudgetRequest struct { + UserPath string `json:"user_path" binding:"required"` + Period string `json:"period,omitempty"` + PeriodSeconds int64 `json:"period_seconds,omitempty"` } type resetBudgetRequest struct { @@ -342,31 +351,12 @@ func budgetStatusResponses(statuses []budget.CheckResult, now time.Time) []budge return responses } -func budgetRouteKey(c *echo.Context) (string, int64, error) { - userPathParam := strings.TrimSpace(c.Param("user_path")) - if userPathParam == "" { - return "", 0, errors.New("user_path path parameter is required") - } - userPath, err := url.PathUnescape(userPathParam) - if err != nil { - return "", 0, fmt.Errorf("invalid user_path path parameter: %w", err) - } - userPath, err = budget.NormalizeUserPath(userPath) +func budgetRequestKey(rawUserPath, period string, periodSeconds int64) (string, int64, error) { + userPath, err := budget.NormalizeUserPath(rawUserPath) if err != nil { return "", 0, err } - - periodParam := strings.TrimSpace(c.Param("period")) - if periodParam == "" { - return "", 0, errors.New("period path parameter is required") - } - if seconds, err := strconv.ParseInt(periodParam, 10, 64); err == nil { - if seconds <= 0 { - return "", 0, errors.New("period_seconds must be greater than 0") - } - return userPath, seconds, nil - } - periodSeconds, err := budgetRequestPeriodSeconds(periodParam, 0) + periodSeconds, err = budgetRequestPeriodSeconds(period, periodSeconds) if err != nil { return "", 0, err } @@ -377,9 +367,16 @@ func budgetRequestPeriodSeconds(period string, periodSeconds int64) (int64, erro if periodSeconds > 0 { return periodSeconds, nil } + period = strings.TrimSpace(period) if parsed, ok := budget.PeriodSeconds(period); ok { return parsed, nil } + if parsed, err := strconv.ParseInt(period, 10, 64); err == nil { + if parsed <= 0 { + return 0, errors.New("period_seconds must be greater than 0") + } + return parsed, nil + } return 0, errors.New("period must be one of hourly, daily, weekly, monthly or period_seconds must be set") } diff --git a/internal/admin/handler_budgets_test.go b/internal/admin/handler_budgets_test.go index dd156f4c..8c6684b1 100644 --- a/internal/admin/handler_budgets_test.go +++ b/internal/admin/handler_budgets_test.go @@ -164,16 +164,12 @@ func TestBudgetEndpointsUpsertAndResetOneBudget(t *testing.T) { upsertReq := httptest.NewRequest( http.MethodPut, - "/admin/api/v1/budgets/%2Fteam%2Fbeta/weekly", - strings.NewReader(`{"amount":12.5}`), + "/admin/api/v1/budgets", + strings.NewReader(`{"user_path":"/team/beta","period":"weekly","amount":12.5}`), ) upsertReq.Header.Set("Content-Type", "application/json") upsertRec := httptest.NewRecorder() upsertCtx := e.NewContext(upsertReq, upsertRec) - upsertCtx.SetPathValues(echo.PathValues{ - {Name: "user_path", Value: "%2Fteam%2Fbeta"}, - {Name: "period", Value: "weekly"}, - }) if err := h.UpsertBudget(upsertCtx); err != nil { t.Fatalf("UpsertBudget() failed: %v", err) } @@ -213,16 +209,12 @@ func TestBudgetEndpointsUpsertMarksConfigBudgetManual(t *testing.T) { e := echo.New() req := httptest.NewRequest( http.MethodPut, - "/admin/api/v1/budgets/%2Fteam/daily", - strings.NewReader(`{"amount":12.5}`), + "/admin/api/v1/budgets", + strings.NewReader(`{"user_path":"/team","period":"daily","amount":12.5}`), ) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() c := e.NewContext(req, rec) - c.SetPathValues(echo.PathValues{ - {Name: "user_path", Value: "%2Fteam"}, - {Name: "period", Value: "daily"}, - }) if err := h.UpsertBudget(c); err != nil { t.Fatalf("UpsertBudget() failed: %v", err) @@ -238,6 +230,30 @@ func TestBudgetEndpointsUpsertMarksConfigBudgetManual(t *testing.T) { } } +func TestBudgetEndpointsUpsertAcceptsNumericPeriodString(t *testing.T) { + store := &adminBudgetStore{} + h := newBudgetHandler(t, store) + e := echo.New() + req := httptest.NewRequest( + http.MethodPut, + "/admin/api/v1/budgets", + strings.NewReader(`{"user_path":"/team","period":"604800","amount":12.5}`), + ) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + + if err := h.UpsertBudget(c); err != nil { + t.Fatalf("UpsertBudget() failed: %v", err) + } + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + if len(store.budgets) != 1 || store.budgets[0].PeriodSeconds != budget.PeriodWeeklySeconds { + t.Fatalf("stored budgets = %+v, want weekly budget", store.budgets) + } +} + func TestBudgetEndpointsDeleteBudget(t *testing.T) { store := &adminBudgetStore{ budgets: []budget.Budget{ @@ -249,15 +265,12 @@ func TestBudgetEndpointsDeleteBudget(t *testing.T) { e := echo.New() req := httptest.NewRequest( http.MethodDelete, - "/admin/api/v1/budgets/%2Fteam%2Fbeta/604800", - nil, + "/admin/api/v1/budgets", + strings.NewReader(`{"user_path":"/team/beta","period_seconds":604800}`), ) + req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() c := e.NewContext(req, rec) - c.SetPathValues(echo.PathValues{ - {Name: "user_path", Value: "%2Fteam%2Fbeta"}, - {Name: "period", Value: "604800"}, - }) if err := h.DeleteBudget(c); err != nil { t.Fatalf("DeleteBudget() failed: %v", err) @@ -282,13 +295,10 @@ func TestBudgetEndpointsMissingMutationsReturnNotFound(t *testing.T) { store.deleteErr = budget.ErrNotFound }, run: func(h *Handler, e *echo.Echo) *httptest.ResponseRecorder { - req := httptest.NewRequest(http.MethodDelete, "/admin/api/v1/budgets/%2Fteam/86400", nil) + req := httptest.NewRequest(http.MethodDelete, "/admin/api/v1/budgets", strings.NewReader(`{"user_path":"/team","period_seconds":86400}`)) + req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() c := e.NewContext(req, rec) - c.SetPathValues(echo.PathValues{ - {Name: "user_path", Value: "%2Fteam"}, - {Name: "period", Value: "86400"}, - }) if err := h.DeleteBudget(c); err != nil { t.Fatalf("DeleteBudget() returned handler error: %v", err) } diff --git a/internal/admin/handler_guardrails.go b/internal/admin/handler_guardrails.go index 17feaa51..fa2d3c89 100644 --- a/internal/admin/handler_guardrails.go +++ b/internal/admin/handler_guardrails.go @@ -13,12 +13,17 @@ import ( ) type upsertGuardrailRequest struct { + Name string `json:"name" binding:"required"` Type string `json:"type"` Description string `json:"description,omitempty"` UserPath string `json:"user_path,omitempty"` Config json.RawMessage `json:"config"` } +type deleteGuardrailRequest struct { + Name string `json:"name" binding:"required"` +} + func (h *Handler) ListGuardrailTypes(c *echo.Context) error { if h.guardrailDefs == nil { return handleError(c, featureUnavailableError("guardrails feature is unavailable")) @@ -38,21 +43,20 @@ func (h *Handler) ListGuardrails(c *echo.Context) error { return c.JSON(http.StatusOK, views) } -// UpsertGuardrail handles PUT /admin/api/v1/guardrails/{name} +// UpsertGuardrail handles PUT /admin/api/v1/guardrails func (h *Handler) UpsertGuardrail(c *echo.Context) error { if h.guardrailDefs == nil { return handleError(c, featureUnavailableError("guardrails feature is unavailable")) } - name := strings.TrimSpace(c.Param("name")) - if name == "" { - return handleError(c, core.NewInvalidRequestError("guardrail name is required", nil)) - } - var req upsertGuardrailRequest if err := c.Bind(&req); err != nil { return handleError(c, core.NewInvalidRequestError("invalid request body: "+err.Error(), err)) } + name := strings.TrimSpace(req.Name) + if name == "" { + return handleError(c, core.NewInvalidRequestError("guardrail name is required", nil)) + } userPath, err := normalizeUserPathQueryParam("user_path", req.UserPath) if err != nil { @@ -82,13 +86,17 @@ func (h *Handler) UpsertGuardrail(c *echo.Context) error { return c.JSON(http.StatusOK, guardrails.ViewFromDefinition(*definition)) } -// DeleteGuardrail handles DELETE /admin/api/v1/guardrails/{name} +// DeleteGuardrail handles DELETE /admin/api/v1/guardrails func (h *Handler) DeleteGuardrail(c *echo.Context) error { if h.guardrailDefs == nil { return handleError(c, featureUnavailableError("guardrails feature is unavailable")) } - name := strings.TrimSpace(c.Param("name")) + var req deleteGuardrailRequest + if err := c.Bind(&req); err != nil { + return handleError(c, core.NewInvalidRequestError("invalid request body: "+err.Error(), err)) + } + name := strings.TrimSpace(req.Name) if name == "" { return handleError(c, core.NewInvalidRequestError("guardrail name is required", nil)) } diff --git a/internal/admin/handler_guardrails_test.go b/internal/admin/handler_guardrails_test.go index 1602b677..7a158393 100644 --- a/internal/admin/handler_guardrails_test.go +++ b/internal/admin/handler_guardrails_test.go @@ -182,7 +182,8 @@ func TestUpsertGuardrail(t *testing.T) { h := newGuardrailHandler(t) e := echo.New() - req := httptest.NewRequest(http.MethodPut, "/admin/api/v1/guardrails/policy-system", bytes.NewBufferString(`{ + req := httptest.NewRequest(http.MethodPut, "/admin/api/v1/guardrails", bytes.NewBufferString(`{ + "name":"policy-system", "type":"system_prompt", "description":"Default policy", "user_path":"team/alpha", @@ -191,8 +192,6 @@ func TestUpsertGuardrail(t *testing.T) { req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() c := e.NewContext(req, rec) - c.SetPath("/admin/api/v1/guardrails/:name") - c.SetPathValues(echo.PathValues{{Name: "name", Value: "policy-system"}}) if err := h.UpsertGuardrail(c); err != nil { t.Fatalf("UpsertGuardrail() error = %v", err) @@ -217,7 +216,8 @@ func TestUpsertGuardrailLLMBasedAltering(t *testing.T) { h := newGuardrailHandler(t) e := echo.New() - req := httptest.NewRequest(http.MethodPut, "/admin/api/v1/guardrails/privacy", bytes.NewBufferString(`{ + req := httptest.NewRequest(http.MethodPut, "/admin/api/v1/guardrails", bytes.NewBufferString(`{ + "name":"privacy", "type":"llm_based_altering", "description":"Rewrite user PII", "config":{"model":"gpt-4o-mini","roles":["user","tool"]} @@ -225,8 +225,6 @@ func TestUpsertGuardrailLLMBasedAltering(t *testing.T) { req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() c := e.NewContext(req, rec) - c.SetPath("/admin/api/v1/guardrails/:name") - c.SetPathValues(echo.PathValues{{Name: "name", Value: "privacy"}}) if err := h.UpsertGuardrail(c); err != nil { t.Fatalf("UpsertGuardrail() error = %v", err) @@ -259,7 +257,8 @@ func TestUpsertGuardrailLLMBasedAlteringNormalizesProviderHintIntoModel(t *testi h := newGuardrailHandler(t) e := echo.New() - req := httptest.NewRequest(http.MethodPut, "/admin/api/v1/guardrails/privacy", bytes.NewBufferString(`{ + req := httptest.NewRequest(http.MethodPut, "/admin/api/v1/guardrails", bytes.NewBufferString(`{ + "name":"privacy", "type":"llm_based_altering", "description":"Rewrite user PII", "config":{"model":"gpt-4o-mini","provider":"openai","roles":["user"]} @@ -267,8 +266,6 @@ func TestUpsertGuardrailLLMBasedAlteringNormalizesProviderHintIntoModel(t *testi req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() c := e.NewContext(req, rec) - c.SetPath("/admin/api/v1/guardrails/:name") - c.SetPathValues(echo.PathValues{{Name: "name", Value: "privacy"}}) if err := h.UpsertGuardrail(c); err != nil { t.Fatalf("UpsertGuardrail() error = %v", err) @@ -298,15 +295,14 @@ func TestUpsertGuardrailRejectsSlashInName(t *testing.T) { h := newGuardrailHandler(t) e := echo.New() - req := httptest.NewRequest(http.MethodPut, "/admin/api/v1/guardrails/privacy/redactor", bytes.NewBufferString(`{ + req := httptest.NewRequest(http.MethodPut, "/admin/api/v1/guardrails", bytes.NewBufferString(`{ + "name":"privacy/redactor", "type":"llm_based_altering", "config":{"model":"gpt-4o-mini","roles":["user"]} }`)) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() c := e.NewContext(req, rec) - c.SetPath("/admin/api/v1/guardrails/:name") - c.SetPathValues(echo.PathValues{{Name: "name", Value: "privacy/redactor"}}) if err := h.UpsertGuardrail(c); err != nil { t.Fatalf("UpsertGuardrail() error = %v", err) @@ -367,11 +363,10 @@ func TestDeleteGuardrailRejectsActiveWorkflowReference(t *testing.T) { h := NewHandler(nil, nil, WithGuardrailService(guardrailService), WithWorkflows(planService)) e := echo.New() - req := httptest.NewRequest(http.MethodDelete, "/admin/api/v1/guardrails/policy-system", nil) + req := httptest.NewRequest(http.MethodDelete, "/admin/api/v1/guardrails", bytes.NewBufferString(`{"name":"policy-system"}`)) + req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() c := e.NewContext(req, rec) - c.SetPath("/admin/api/v1/guardrails/:name") - c.SetPathValues(echo.PathValues{{Name: "name", Value: "policy-system"}}) if err := h.DeleteGuardrail(c); err != nil { t.Fatalf("DeleteGuardrail() error = %v", err) @@ -423,11 +418,10 @@ func TestDeleteGuardrailIgnoresDisabledWorkflowGuardrailRefs(t *testing.T) { h := NewHandler(nil, nil, WithGuardrailService(guardrailService), WithWorkflows(planService)) e := echo.New() - req := httptest.NewRequest(http.MethodDelete, "/admin/api/v1/guardrails/policy-system", nil) + req := httptest.NewRequest(http.MethodDelete, "/admin/api/v1/guardrails", bytes.NewBufferString(`{"name":"policy-system"}`)) + req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() c := e.NewContext(req, rec) - c.SetPath("/admin/api/v1/guardrails/:name") - c.SetPathValues(echo.PathValues{{Name: "name", Value: "policy-system"}}) if err := h.DeleteGuardrail(c); err != nil { t.Fatalf("DeleteGuardrail() error = %v", err) diff --git a/internal/admin/handler_model_overrides.go b/internal/admin/handler_model_overrides.go index b28707c0..528ab319 100644 --- a/internal/admin/handler_model_overrides.go +++ b/internal/admin/handler_model_overrides.go @@ -1,7 +1,7 @@ package admin import ( - "context" + "errors" "log/slog" "net/http" @@ -12,9 +12,14 @@ import ( ) type upsertModelOverrideRequest struct { + Selector string `json:"selector" binding:"required"` UserPaths []string `json:"user_paths,omitempty"` } +type deleteModelOverrideRequest struct { + Selector string `json:"selector" binding:"required"` +} + // ListModelOverrides handles GET /admin/api/v1/model-overrides. // // @Summary List model access overrides @@ -38,22 +43,21 @@ func (h *Handler) ListModelOverrides(c *echo.Context) error { return c.JSON(http.StatusOK, views) } -// UpsertModelOverride handles PUT /admin/api/v1/model-overrides/{selector}. +// UpsertModelOverride handles PUT /admin/api/v1/model-overrides. // // @Summary Create or update one model access override // @Tags admin // @Accept json // @Produce json // @Security BearerAuth -// @Param selector path string true "URL-encoded model selector such as /, openai/, gpt-4o-mini, or openai/gpt-4o-mini" -// @Param override body upsertModelOverrideRequest true "Allowed user paths" +// @Param override body upsertModelOverrideRequest true "Model selector and allowed user paths" // @Success 200 {object} modeloverrides.View // @Failure 400 {object} core.GatewayError // @Failure 401 {object} core.GatewayError // @Failure 500 {object} core.GatewayError // @Failure 502 {object} core.GatewayError // @Failure 503 {object} core.GatewayError -// @Router /admin/api/v1/model-overrides/{selector} [put] +// @Router /admin/api/v1/model-overrides [put] // //nolint:dupl // structurally similar to UpsertModelPricingOverride but operates on different types and stores. func (h *Handler) UpsertModelOverride(c *echo.Context) error { @@ -61,15 +65,14 @@ func (h *Handler) UpsertModelOverride(c *echo.Context) error { return handleError(c, featureUnavailableError("model overrides feature is unavailable")) } - selector, err := decodeModelOverridePathSelector(c.Param("selector")) - if err != nil { - return handleError(c, err) - } - var req upsertModelOverrideRequest if err := c.Bind(&req); err != nil { return handleError(c, core.NewInvalidRequestError("invalid request body: "+err.Error(), err)) } + selector, err := normalizeModelOverrideSelector(req.Selector) + if err != nil { + return handleError(c, err) + } if err := h.modelOverrides.Upsert(c.Request().Context(), modeloverrides.Override{ Selector: selector, @@ -89,36 +92,42 @@ func (h *Handler) UpsertModelOverride(c *echo.Context) error { }) } -// DeleteModelOverride handles DELETE /admin/api/v1/model-overrides/{selector}. +// DeleteModelOverride handles DELETE /admin/api/v1/model-overrides. // // @Summary Delete one model access override // @Tags admin +// @Accept json // @Produce json // @Security BearerAuth -// @Param selector path string true "URL-encoded model selector" +// @Param request body deleteModelOverrideRequest true "Model selector to remove" // @Success 204 "No Content" // @Failure 400 {object} core.GatewayError // @Failure 401 {object} core.GatewayError // @Failure 404 {object} core.GatewayError // @Failure 502 {object} core.GatewayError // @Failure 503 {object} core.GatewayError -// @Router /admin/api/v1/model-overrides/{selector} [delete] +// @Router /admin/api/v1/model-overrides [delete] +// +//nolint:dupl // structurally similar to DeleteModelPricingOverride but operates on different types and stores. func (h *Handler) DeleteModelOverride(c *echo.Context) error { - var unavailableErr error - var deleteFunc func(context.Context, string) error if h.modelOverrides == nil { - unavailableErr = featureUnavailableError("model overrides feature is unavailable") - } else { - deleteFunc = h.modelOverrides.Delete + return handleError(c, featureUnavailableError("model overrides feature is unavailable")) + } + + var req deleteModelOverrideRequest + if err := c.Bind(&req); err != nil { + return handleError(c, core.NewInvalidRequestError("invalid request body: "+err.Error(), err)) + } + selector, err := normalizeModelOverrideSelector(req.Selector) + if err != nil { + return handleError(c, err) + } + + if err := h.modelOverrides.Delete(c.Request().Context(), selector); err != nil { + if errors.Is(err, modeloverrides.ErrNotFound) { + return handleError(c, core.NewNotFoundError("model override not found: "+selector)) + } + return handleError(c, modelOverrideWriteError(err)) } - return deleteByName( - c, - unavailableErr, - "selector", - decodeModelOverridePathSelector, - deleteFunc, - modeloverrides.ErrNotFound, - "model override not found: ", - modelOverrideWriteError, - ) + return c.NoContent(http.StatusNoContent) } diff --git a/internal/admin/handler_model_overrides_test.go b/internal/admin/handler_model_overrides_test.go index 2a62ae1f..04aad9db 100644 --- a/internal/admin/handler_model_overrides_test.go +++ b/internal/admin/handler_model_overrides_test.go @@ -259,17 +259,16 @@ func TestModelOverrideEndpointsReturn503WhenServiceUnavailable(t *testing.T) { listCtx, listRec := newHandlerContext("/admin/api/v1/model-overrides") assertUnavailable("ListModelOverrides", h.ListModelOverrides(listCtx), listRec) - putReq := httptest.NewRequest(http.MethodPut, "/admin/api/v1/model-overrides/openai%2Fgpt-4o", bytes.NewBufferString(`{"user_paths":["/"]}`)) + putReq := httptest.NewRequest(http.MethodPut, "/admin/api/v1/model-overrides", bytes.NewBufferString(`{"selector":"openai/gpt-4o","user_paths":["/"]}`)) putReq.Header.Set("Content-Type", "application/json") putRec := httptest.NewRecorder() putCtx := e.NewContext(putReq, putRec) - putCtx.SetPathValues(echo.PathValues{{Name: "selector", Value: "openai/gpt-4o"}}) assertUnavailable("UpsertModelOverride", h.UpsertModelOverride(putCtx), putRec) - deleteReq := httptest.NewRequest(http.MethodDelete, "/admin/api/v1/model-overrides/openai%2Fgpt-4o", nil) + deleteReq := httptest.NewRequest(http.MethodDelete, "/admin/api/v1/model-overrides", bytes.NewBufferString(`{"selector":"openai/gpt-4o"}`)) + deleteReq.Header.Set("Content-Type", "application/json") deleteRec := httptest.NewRecorder() deleteCtx := e.NewContext(deleteReq, deleteRec) - deleteCtx.SetPathValues(echo.PathValues{{Name: "selector", Value: "openai/gpt-4o"}}) assertUnavailable("DeleteModelOverride", h.DeleteModelOverride(deleteCtx), deleteRec) } @@ -278,11 +277,11 @@ func TestUpsertAndDeleteModelOverride(t *testing.T) { h := NewHandler(nil, nil, WithModelOverrides(service)) e := echo.New() - putReq := httptest.NewRequest(http.MethodPut, "/admin/api/v1/model-overrides/openai%2Fgpt-4o", bytes.NewBufferString(`{"user_paths":["team/alpha"]}`)) + selector := "openai/meta-llama/llama-3.1-8b-instruct" + putReq := httptest.NewRequest(http.MethodPut, "/admin/api/v1/model-overrides", bytes.NewBufferString(`{"selector":"`+selector+`","user_paths":["team/alpha"]}`)) putReq.Header.Set("Content-Type", "application/json") putRec := httptest.NewRecorder() putCtx := e.NewContext(putReq, putRec) - putCtx.SetPathValues(echo.PathValues{{Name: "selector", Value: "openai/gpt-4o"}}) if err := h.UpsertModelOverride(putCtx); err != nil { t.Fatalf("UpsertModelOverride() error = %v", err) @@ -295,8 +294,8 @@ func TestUpsertAndDeleteModelOverride(t *testing.T) { if err := json.Unmarshal(putRec.Body.Bytes(), &body); err != nil { t.Fatalf("decode upsert response: %v", err) } - if body.Selector != "openai/gpt-4o" { - t.Fatalf("body.Selector = %q, want openai/gpt-4o", body.Selector) + if body.Selector != selector { + t.Fatalf("body.Selector = %q, want %q", body.Selector, selector) } if body.ScopeKind != modelselectors.ScopeProviderModel { t.Fatalf("body.ScopeKind = %q, want %q", body.ScopeKind, modelselectors.ScopeProviderModel) @@ -305,10 +304,10 @@ func TestUpsertAndDeleteModelOverride(t *testing.T) { t.Fatalf("body.UserPaths = %#v, want [/team/alpha]", body.UserPaths) } - deleteReq := httptest.NewRequest(http.MethodDelete, "/admin/api/v1/model-overrides/openai%2Fgpt-4o", nil) + deleteReq := httptest.NewRequest(http.MethodDelete, "/admin/api/v1/model-overrides", bytes.NewBufferString(`{"selector":"`+selector+`"}`)) + deleteReq.Header.Set("Content-Type", "application/json") deleteRec := httptest.NewRecorder() deleteCtx := e.NewContext(deleteReq, deleteRec) - deleteCtx.SetPathValues(echo.PathValues{{Name: "selector", Value: "openai/gpt-4o"}}) if err := h.DeleteModelOverride(deleteCtx); err != nil { t.Fatalf("DeleteModelOverride() error = %v", err) @@ -323,11 +322,10 @@ func TestUpsertAndDeleteProviderWideModelOverride(t *testing.T) { h := NewHandler(nil, nil, WithModelOverrides(service)) e := echo.New() - putReq := httptest.NewRequest(http.MethodPut, "/admin/api/v1/model-overrides/openai%2F", bytes.NewBufferString(`{"user_paths":["/non-existing"]}`)) + putReq := httptest.NewRequest(http.MethodPut, "/admin/api/v1/model-overrides", bytes.NewBufferString(`{"selector":"openai/","user_paths":["/non-existing"]}`)) putReq.Header.Set("Content-Type", "application/json") putRec := httptest.NewRecorder() putCtx := e.NewContext(putReq, putRec) - putCtx.SetPathValues(echo.PathValues{{Name: "selector", Value: "openai/"}}) if err := h.UpsertModelOverride(putCtx); err != nil { t.Fatalf("UpsertModelOverride() error = %v", err) @@ -347,10 +345,10 @@ func TestUpsertAndDeleteProviderWideModelOverride(t *testing.T) { t.Fatalf("body.UserPaths = %#v, want [/non-existing]", body.UserPaths) } - deleteReq := httptest.NewRequest(http.MethodDelete, "/admin/api/v1/model-overrides/openai%2F", nil) + deleteReq := httptest.NewRequest(http.MethodDelete, "/admin/api/v1/model-overrides", bytes.NewBufferString(`{"selector":"openai/"}`)) + deleteReq.Header.Set("Content-Type", "application/json") deleteRec := httptest.NewRecorder() deleteCtx := e.NewContext(deleteReq, deleteRec) - deleteCtx.SetPathValues(echo.PathValues{{Name: "selector", Value: "openai/"}}) if err := h.DeleteModelOverride(deleteCtx); err != nil { t.Fatalf("DeleteModelOverride() error = %v", err) @@ -365,11 +363,10 @@ func TestUpsertAndDeleteGlobalModelOverride(t *testing.T) { h := NewHandler(nil, nil, WithModelOverrides(service)) e := echo.New() - putReq := httptest.NewRequest(http.MethodPut, "/admin/api/v1/model-overrides/%2F", bytes.NewBufferString(`{"user_paths":["/"]}`)) + putReq := httptest.NewRequest(http.MethodPut, "/admin/api/v1/model-overrides", bytes.NewBufferString(`{"selector":"/","user_paths":["/"]}`)) putReq.Header.Set("Content-Type", "application/json") putRec := httptest.NewRecorder() putCtx := e.NewContext(putReq, putRec) - putCtx.SetPathValues(echo.PathValues{{Name: "selector", Value: "/"}}) if err := h.UpsertModelOverride(putCtx); err != nil { t.Fatalf("UpsertModelOverride() error = %v", err) @@ -389,10 +386,10 @@ func TestUpsertAndDeleteGlobalModelOverride(t *testing.T) { t.Fatalf("body.UserPaths = %#v, want [/]", body.UserPaths) } - deleteReq := httptest.NewRequest(http.MethodDelete, "/admin/api/v1/model-overrides/%2F", nil) + deleteReq := httptest.NewRequest(http.MethodDelete, "/admin/api/v1/model-overrides", bytes.NewBufferString(`{"selector":"/"}`)) + deleteReq.Header.Set("Content-Type", "application/json") deleteRec := httptest.NewRecorder() deleteCtx := e.NewContext(deleteReq, deleteRec) - deleteCtx.SetPathValues(echo.PathValues{{Name: "selector", Value: "/"}}) if err := h.DeleteModelOverride(deleteCtx); err != nil { t.Fatalf("DeleteModelOverride() error = %v", err) @@ -407,11 +404,10 @@ func TestUpsertModelOverrideReturnsBadRequestForValidationErrors(t *testing.T) { h := NewHandler(nil, nil, WithModelOverrides(service)) e := echo.New() - req := httptest.NewRequest(http.MethodPut, "/admin/api/v1/model-overrides/openai%2Fgpt-4o", bytes.NewBufferString(`{"user_paths":[]}`)) + req := httptest.NewRequest(http.MethodPut, "/admin/api/v1/model-overrides", bytes.NewBufferString(`{"selector":"openai/gpt-4o","user_paths":[]}`)) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() c := e.NewContext(req, rec) - c.SetPathValues(echo.PathValues{{Name: "selector", Value: "openai/gpt-4o"}}) if err := h.UpsertModelOverride(c); err != nil { t.Fatalf("UpsertModelOverride() error = %v", err) @@ -428,11 +424,10 @@ func TestModelOverrideWriteErrorsBubbleProviderErrors(t *testing.T) { h := NewHandler(nil, nil, WithModelOverrides(service)) e := echo.New() - req := httptest.NewRequest(http.MethodPut, "/admin/api/v1/model-overrides/openai%2Fgpt-4o", bytes.NewBufferString(`{"user_paths":["/"]}`)) + req := httptest.NewRequest(http.MethodPut, "/admin/api/v1/model-overrides", bytes.NewBufferString(`{"selector":"openai/gpt-4o","user_paths":["/"]}`)) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() c := e.NewContext(req, rec) - c.SetPathValues(echo.PathValues{{Name: "selector", Value: "openai/gpt-4o"}}) if err := h.UpsertModelOverride(c); err != nil { t.Fatalf("UpsertModelOverride() error = %v", err) @@ -460,10 +455,10 @@ func TestDeleteModelOverrideWriteErrorsBubbleProviderErrors(t *testing.T) { h := NewHandler(nil, nil, WithModelOverrides(service)) e := echo.New() - req := httptest.NewRequest(http.MethodDelete, "/admin/api/v1/model-overrides/openai%2Fgpt-4o", nil) + req := httptest.NewRequest(http.MethodDelete, "/admin/api/v1/model-overrides", bytes.NewBufferString(`{"selector":"openai/gpt-4o"}`)) + req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() c := e.NewContext(req, rec) - c.SetPathValues(echo.PathValues{{Name: "selector", Value: "openai/gpt-4o"}}) if err := h.DeleteModelOverride(c); err != nil { t.Fatalf("DeleteModelOverride() error = %v", err) diff --git a/internal/admin/handler_model_pricing_overrides.go b/internal/admin/handler_model_pricing_overrides.go index df0b3968..9ac968cf 100644 --- a/internal/admin/handler_model_pricing_overrides.go +++ b/internal/admin/handler_model_pricing_overrides.go @@ -1,7 +1,7 @@ package admin import ( - "context" + "errors" "log/slog" "net/http" @@ -12,7 +12,12 @@ import ( ) type upsertModelPricingOverrideRequest struct { - Pricing pricingoverrides.Pricing `json:"pricing" binding:"required"` + Selector string `json:"selector" binding:"required"` + Pricing pricingoverrides.Pricing `json:"pricing" binding:"required"` +} + +type deleteModelPricingOverrideRequest struct { + Selector string `json:"selector" binding:"required"` } // ListModelPricingOverrides handles GET /admin/api/v1/model-pricing-overrides. @@ -37,7 +42,7 @@ func (h *Handler) ListModelPricingOverrides(c *echo.Context) error { return c.JSON(http.StatusOK, views) } -// UpsertModelPricingOverride handles PUT /admin/api/v1/model-pricing-overrides/{selector}. +// UpsertModelPricingOverride handles PUT /admin/api/v1/model-pricing-overrides. // // @Summary Create or update one model pricing override // @Description Stores USD-only pricing for one selector. More precise selectors override broader selectors at runtime. @@ -45,14 +50,13 @@ func (h *Handler) ListModelPricingOverrides(c *echo.Context) error { // @Accept json // @Produce json // @Security BearerAuth -// @Param selector path string true "URL-encoded pricing selector such as /, openai/, gpt-4o-mini, or openai/gpt-4o-mini" -// @Param override body upsertModelPricingOverrideRequest true "Pricing override" +// @Param override body upsertModelPricingOverrideRequest true "Pricing selector and override" // @Success 200 {object} pricingoverrides.View // @Failure 400 {object} core.GatewayError // @Failure 401 {object} core.GatewayError // @Failure 500 {object} core.GatewayError // @Failure 503 {object} core.GatewayError -// @Router /admin/api/v1/model-pricing-overrides/{selector} [put] +// @Router /admin/api/v1/model-pricing-overrides [put] // //nolint:dupl // structurally similar to UpsertModelOverride but operates on different types and stores. func (h *Handler) UpsertModelPricingOverride(c *echo.Context) error { @@ -60,15 +64,14 @@ func (h *Handler) UpsertModelPricingOverride(c *echo.Context) error { return handleError(c, featureUnavailableError("model pricing overrides feature is unavailable")) } - selector, err := decodeModelPricingOverridePathSelector(c.Param("selector")) - if err != nil { - return handleError(c, err) - } - var req upsertModelPricingOverrideRequest if err := c.Bind(&req); err != nil { return handleError(c, core.NewInvalidRequestError("invalid request body: "+err.Error(), err)) } + selector, err := normalizeModelPricingOverrideSelector(req.Selector) + if err != nil { + return handleError(c, err) + } if err := h.pricingOverrides.Upsert(c.Request().Context(), pricingoverrides.Override{ Selector: selector, @@ -85,35 +88,41 @@ func (h *Handler) UpsertModelPricingOverride(c *echo.Context) error { return c.JSON(http.StatusOK, view) } -// DeleteModelPricingOverride handles DELETE /admin/api/v1/model-pricing-overrides/{selector}. +// DeleteModelPricingOverride handles DELETE /admin/api/v1/model-pricing-overrides. // // @Summary Delete one model pricing override // @Tags admin +// @Accept json // @Produce json // @Security BearerAuth -// @Param selector path string true "URL-encoded pricing selector" +// @Param request body deleteModelPricingOverrideRequest true "Pricing selector to remove" // @Success 204 "No Content" // @Failure 400 {object} core.GatewayError // @Failure 401 {object} core.GatewayError // @Failure 404 {object} core.GatewayError // @Failure 503 {object} core.GatewayError -// @Router /admin/api/v1/model-pricing-overrides/{selector} [delete] +// @Router /admin/api/v1/model-pricing-overrides [delete] +// +//nolint:dupl // structurally similar to DeleteModelOverride but operates on different types and stores. func (h *Handler) DeleteModelPricingOverride(c *echo.Context) error { - var unavailableErr error - var deleteFunc func(context.Context, string) error if h.pricingOverrides == nil { - unavailableErr = featureUnavailableError("model pricing overrides feature is unavailable") - } else { - deleteFunc = h.pricingOverrides.Delete + return handleError(c, featureUnavailableError("model pricing overrides feature is unavailable")) + } + + var req deleteModelPricingOverrideRequest + if err := c.Bind(&req); err != nil { + return handleError(c, core.NewInvalidRequestError("invalid request body: "+err.Error(), err)) + } + selector, err := normalizeModelPricingOverrideSelector(req.Selector) + if err != nil { + return handleError(c, err) + } + + if err := h.pricingOverrides.Delete(c.Request().Context(), selector); err != nil { + if errors.Is(err, pricingoverrides.ErrNotFound) { + return handleError(c, core.NewNotFoundError("model pricing override not found: "+selector)) + } + return handleError(c, pricingOverrideWriteError(err)) } - return deleteByName( - c, - unavailableErr, - "selector", - decodeModelPricingOverridePathSelector, - deleteFunc, - pricingoverrides.ErrNotFound, - "model pricing override not found: ", - pricingOverrideWriteError, - ) + return c.NoContent(http.StatusNoContent) } diff --git a/internal/admin/handler_model_pricing_overrides_test.go b/internal/admin/handler_model_pricing_overrides_test.go index 809cef54..741763e7 100644 --- a/internal/admin/handler_model_pricing_overrides_test.go +++ b/internal/admin/handler_model_pricing_overrides_test.go @@ -78,7 +78,6 @@ func TestModelPricingOverrideLifecycle(t *testing.T) { name string providers []string selector string - encodedPath string price float64 wantProvider string wantModel string @@ -88,7 +87,6 @@ func TestModelPricingOverrideLifecycle(t *testing.T) { name: "simple provider model selector", providers: []string{"openai"}, selector: "openai/gpt-4o", - encodedPath: "openai%2Fgpt-4o", price: 1.25, wantProvider: "openai", wantModel: "gpt-4o", @@ -98,7 +96,6 @@ func TestModelPricingOverrideLifecycle(t *testing.T) { name: "provider model selector with slash-shaped model id", providers: []string{"openrouter"}, selector: "openrouter/meta-llama/llama-3.1-8b-instruct", - encodedPath: "openrouter%2Fmeta-llama%2Fllama-3.1-8b-instruct", price: 0.18, wantProvider: "openrouter", wantModel: "meta-llama/llama-3.1-8b-instruct", @@ -113,8 +110,8 @@ func TestModelPricingOverrideLifecycle(t *testing.T) { e := echo.New() h.RegisterRoutes(e.Group("/admin/api/v1")) - bodyJSON := `{"pricing":{"input_per_mtok":` + strconv.FormatFloat(tt.price, 'f', -1, 64) + `}}` - putReq := httptest.NewRequest(http.MethodPut, "/admin/api/v1/model-pricing-overrides/"+tt.encodedPath, bytes.NewBufferString(bodyJSON)) + bodyJSON := `{"selector":"` + tt.selector + `","pricing":{"input_per_mtok":` + strconv.FormatFloat(tt.price, 'f', -1, 64) + `}}` + putReq := httptest.NewRequest(http.MethodPut, "/admin/api/v1/model-pricing-overrides", bytes.NewBufferString(bodyJSON)) putReq.Header.Set("Content-Type", "application/json") putRec := httptest.NewRecorder() e.ServeHTTP(putRec, putReq) @@ -143,7 +140,8 @@ func TestModelPricingOverrideLifecycle(t *testing.T) { } assertPricingOverrideView(t, listBody[0], tt.selector, tt.wantProvider, tt.wantModel, tt.wantScope, tt.price) - deleteReq := httptest.NewRequest(http.MethodDelete, "/admin/api/v1/model-pricing-overrides/"+tt.encodedPath, nil) + deleteReq := httptest.NewRequest(http.MethodDelete, "/admin/api/v1/model-pricing-overrides", bytes.NewBufferString(`{"selector":"`+tt.selector+`"}`)) + deleteReq.Header.Set("Content-Type", "application/json") deleteRec := httptest.NewRecorder() e.ServeHTTP(deleteRec, deleteReq) if deleteRec.Code != http.StatusNoContent { @@ -171,11 +169,10 @@ func TestUpsertModelPricingOverrideReturnsBadRequestForValidationErrors(t *testi h := NewHandler(nil, nil, WithPricingOverrides(service)) e := echo.New() - req := httptest.NewRequest(http.MethodPut, "/admin/api/v1/model-pricing-overrides/openai%2Fgpt-4o", bytes.NewBufferString(`{"pricing":{}}`)) + req := httptest.NewRequest(http.MethodPut, "/admin/api/v1/model-pricing-overrides", bytes.NewBufferString(`{"selector":"openai/gpt-4o","pricing":{}}`)) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() c := e.NewContext(req, rec) - c.SetPathValues(echo.PathValues{{Name: "selector", Value: "openai/gpt-4o"}}) if err := h.UpsertModelPricingOverride(c); err != nil { t.Fatalf("UpsertModelPricingOverride() error = %v", err) diff --git a/internal/admin/handler_test.go b/internal/admin/handler_test.go index b1833435..d64d4b6f 100644 --- a/internal/admin/handler_test.go +++ b/internal/admin/handler_test.go @@ -1284,7 +1284,7 @@ func TestAuditLog_NilReaderStillValidatesParams(t *testing.T) { } func TestAuditConversation_NilReaderStillValidatesParams(t *testing.T) { - h := NewHandler(nil, nil) // no audit reader configured + h := NewHandler(nil, nil) // no audit reader configured c, rec := newHandlerContext("/admin/api/v1/audit/conversation") // missing required log_id if err := h.AuditConversation(c); err != nil { @@ -2440,7 +2440,7 @@ func TestHandleError_LogsServerErrorsAtErrorLevel(t *testing.T) { }) e := echo.New() - req := httptest.NewRequest(http.MethodPut, "/admin/api/v1/guardrails/privacy", nil) + req := httptest.NewRequest(http.MethodPut, "/admin/api/v1/guardrails", nil) req = req.WithContext(core.WithRequestID(req.Context(), "admin-error-req-456")) rec := httptest.NewRecorder() c := e.NewContext(req, rec) diff --git a/internal/admin/routes.go b/internal/admin/routes.go index 8d60cf03..ae4c6f79 100644 --- a/internal/admin/routes.go +++ b/internal/admin/routes.go @@ -33,8 +33,8 @@ func (h *Handler) RegisterRoutes(g RouteRegistrar) { g.POST("/runtime/refresh", h.RefreshRuntime) g.GET("/budgets", h.ListBudgets) - g.PUT("/budgets/:user_path/:period", h.UpsertBudget) - g.DELETE("/budgets/:user_path/:period", h.DeleteBudget) + g.PUT("/budgets", h.UpsertBudget) + g.DELETE("/budgets", h.DeleteBudget) g.GET("/budgets/settings", h.BudgetSettings) g.PUT("/budgets/settings", h.UpdateBudgetSettings) g.POST("/budgets/reset-one", h.ResetBudget) @@ -44,25 +44,25 @@ func (h *Handler) RegisterRoutes(g RouteRegistrar) { g.GET("/models/categories", h.ListCategories) g.GET("/model-overrides", h.ListModelOverrides) - g.PUT("/model-overrides/:selector", h.UpsertModelOverride) - g.DELETE("/model-overrides/:selector", h.DeleteModelOverride) + g.PUT("/model-overrides", h.UpsertModelOverride) + g.DELETE("/model-overrides", h.DeleteModelOverride) g.GET("/model-pricing-overrides", h.ListModelPricingOverrides) - g.PUT("/model-pricing-overrides/:selector", h.UpsertModelPricingOverride) - g.DELETE("/model-pricing-overrides/:selector", h.DeleteModelPricingOverride) + g.PUT("/model-pricing-overrides", h.UpsertModelPricingOverride) + g.DELETE("/model-pricing-overrides", h.DeleteModelPricingOverride) g.GET("/auth-keys", h.ListAuthKeys) g.POST("/auth-keys", h.CreateAuthKey) g.POST("/auth-keys/:id/deactivate", h.DeactivateAuthKey) g.GET("/aliases", h.ListAliases) - g.PUT("/aliases/:name", h.UpsertAlias) - g.DELETE("/aliases/:name", h.DeleteAlias) + g.PUT("/aliases", h.UpsertAlias) + g.DELETE("/aliases", h.DeleteAlias) g.GET("/guardrails/types", h.ListGuardrailTypes) g.GET("/guardrails", h.ListGuardrails) - g.PUT("/guardrails/:name", h.UpsertGuardrail) - g.DELETE("/guardrails/:name", h.DeleteGuardrail) + g.PUT("/guardrails", h.UpsertGuardrail) + g.DELETE("/guardrails", h.DeleteGuardrail) g.GET("/workflows", h.ListWorkflows) g.GET("/workflows/guardrails", h.ListWorkflowGuardrails) diff --git a/internal/admin/routes_test.go b/internal/admin/routes_test.go index 277b343e..5af79219 100644 --- a/internal/admin/routes_test.go +++ b/internal/admin/routes_test.go @@ -48,8 +48,8 @@ func TestRegisterRoutes_RegistersExpectedPaths(t *testing.T) { "POST /admin/api/v1/runtime/refresh", "GET /admin/api/v1/budgets", - "PUT /admin/api/v1/budgets/:user_path/:period", - "DELETE /admin/api/v1/budgets/:user_path/:period", + "PUT /admin/api/v1/budgets", + "DELETE /admin/api/v1/budgets", "GET /admin/api/v1/budgets/settings", "PUT /admin/api/v1/budgets/settings", "POST /admin/api/v1/budgets/reset-one", @@ -59,25 +59,25 @@ func TestRegisterRoutes_RegistersExpectedPaths(t *testing.T) { "GET /admin/api/v1/models/categories", "GET /admin/api/v1/model-overrides", - "PUT /admin/api/v1/model-overrides/:selector", - "DELETE /admin/api/v1/model-overrides/:selector", + "PUT /admin/api/v1/model-overrides", + "DELETE /admin/api/v1/model-overrides", "GET /admin/api/v1/model-pricing-overrides", - "PUT /admin/api/v1/model-pricing-overrides/:selector", - "DELETE /admin/api/v1/model-pricing-overrides/:selector", + "PUT /admin/api/v1/model-pricing-overrides", + "DELETE /admin/api/v1/model-pricing-overrides", "GET /admin/api/v1/auth-keys", "POST /admin/api/v1/auth-keys", "POST /admin/api/v1/auth-keys/:id/deactivate", "GET /admin/api/v1/aliases", - "PUT /admin/api/v1/aliases/:name", - "DELETE /admin/api/v1/aliases/:name", + "PUT /admin/api/v1/aliases", + "DELETE /admin/api/v1/aliases", "GET /admin/api/v1/guardrails/types", "GET /admin/api/v1/guardrails", - "PUT /admin/api/v1/guardrails/:name", - "DELETE /admin/api/v1/guardrails/:name", + "PUT /admin/api/v1/guardrails", + "DELETE /admin/api/v1/guardrails", "GET /admin/api/v1/workflows", "GET /admin/api/v1/workflows/guardrails", diff --git a/internal/server/http_test.go b/internal/server/http_test.go index 7ac9a369..ec6a5128 100644 --- a/internal/server/http_test.go +++ b/internal/server/http_test.go @@ -332,7 +332,7 @@ func TestBasePathStripsPrefixBeforeRouting(t *testing.T) { func TestBasePathPreservesEscapedPathParamsBeforeRouting(t *testing.T) { srv := New(&mockProvider{}, &Config{BasePath: "/g"}) - srv.echo.PUT("/admin/api/v1/budgets/:user_path/:period", func(c *echo.Context) error { + srv.echo.PUT("/probe/:user_path/:period", func(c *echo.Context) error { return c.String( http.StatusOK, c.Param("user_path")+"|"+c.Param("period")+"|"+c.Request().URL.RawPath+"|"+c.Request().RequestURI, @@ -347,19 +347,19 @@ func TestBasePathPreservesEscapedPathParamsBeforeRouting(t *testing.T) { }{ { name: "root user path", - path: "/g/admin/api/v1/budgets/%2F/86400", - expected: "%2F|86400|/admin/api/v1/budgets/%2F/86400|/admin/api/v1/budgets/%2F/86400", + path: "/g/probe/%2F/86400", + expected: "%2F|86400|/probe/%2F/86400|/probe/%2F/86400", }, { name: "nested user path", - path: "/g/admin/api/v1/budgets/%2Fteam%2Fbeta/604800", - expected: "%2Fteam%2Fbeta|604800|/admin/api/v1/budgets/%2Fteam%2Fbeta/604800|/admin/api/v1/budgets/%2Fteam%2Fbeta/604800", + path: "/g/probe/%2Fteam%2Fbeta/604800", + expected: "%2Fteam%2Fbeta|604800|/probe/%2Fteam%2Fbeta/604800|/probe/%2Fteam%2Fbeta/604800", }, { name: "encoded base path raw prefix", - path: "/g/admin/api/v1/budgets/%2F/86400", - rawPath: "/%67/admin/api/v1/budgets/%2F/86400", - expected: "%2F|86400|/admin/api/v1/budgets/%2F/86400|/admin/api/v1/budgets/%2F/86400", + path: "/g/probe/%2F/86400", + rawPath: "/%67/probe/%2F/86400", + expected: "%2F|86400|/probe/%2F/86400|/probe/%2F/86400", }, } @@ -385,7 +385,7 @@ func TestBasePathPreservesEscapedPathParamsBeforeRouting(t *testing.T) { func TestBasePathRejectsInvalidRawPathPrefix(t *testing.T) { srv := New(&mockProvider{}, &Config{BasePath: "/g"}) - srv.echo.PUT("/admin/api/v1/budgets/:user_path/:period", func(c *echo.Context) error { + srv.echo.PUT("/probe/:user_path/:period", func(c *echo.Context) error { return c.NoContent(http.StatusOK) }) @@ -395,17 +395,17 @@ func TestBasePathRejectsInvalidRawPathPrefix(t *testing.T) { }{ { name: "decoded raw path does not match base path", - rawPath: "/%2Fg/admin/api/v1/budgets/%2F/86400", + rawPath: "/%2Fg/probe/%2F/86400", }, { name: "encoded slash in raw base path segment", - rawPath: "/%67%2Fadmin/api/v1/budgets/%2F/86400", + rawPath: "/%67%2Fprobe/%2F/86400", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - req := httptest.NewRequest(http.MethodPut, "/g/admin/api/v1/budgets/%2F/86400", nil) + req := httptest.NewRequest(http.MethodPut, "/g/probe/%2F/86400", nil) req.URL.RawPath = tt.rawPath rec := httptest.NewRecorder() diff --git a/tests/e2e/budget_test.go b/tests/e2e/budget_test.go index e8388b39..dc69cca3 100644 --- a/tests/e2e/budget_test.go +++ b/tests/e2e/budget_test.go @@ -87,8 +87,10 @@ func TestBudgetAdminEndpointsSQLite_E2E(t *testing.T) { })) defer ts.Close() - putResp := sendBudgetJSONRequest(t, http.MethodPut, ts.URL+"/admin/api/v1/budgets/%2Fteam%2Fadmin/daily", map[string]any{ - "amount": 12.5, + putResp := sendBudgetJSONRequest(t, http.MethodPut, ts.URL+"/admin/api/v1/budgets", map[string]any{ + "user_path": "/team/admin", + "period": "daily", + "amount": 12.5, }) require.Equal(t, http.StatusOK, putResp.StatusCode) closeBody(putResp) @@ -121,7 +123,10 @@ func TestBudgetAdminEndpointsSQLite_E2E(t *testing.T) { require.Len(t, statuses, 1) require.NotNil(t, statuses[0].Budget.LastResetAt) - deleteResp := sendBudgetJSONRequest(t, http.MethodDelete, ts.URL+"/admin/api/v1/budgets/%2Fteam%2Fadmin/daily", nil) + deleteResp := sendBudgetJSONRequest(t, http.MethodDelete, ts.URL+"/admin/api/v1/budgets", map[string]any{ + "user_path": "/team/admin", + "period": "daily", + }) require.Equal(t, http.StatusOK, deleteResp.StatusCode) closeBody(deleteResp) diff --git a/tests/e2e/release-e2e-scenarios.md b/tests/e2e/release-e2e-scenarios.md index 356f446e..4c6c489d 100644 --- a/tests/e2e/release-e2e-scenarios.md +++ b/tests/e2e/release-e2e-scenarios.md @@ -97,8 +97,6 @@ run_release_budget_enforcement() { local expected_reply="$4" local leaf_path="$budget_path/leaf" - local encoded_path - encoded_path=$(jq -nr --arg value "$budget_path" '$value | @uri') local req1="qa-budget-$artifact_prefix-$QA_SUFFIX-1" local req2="qa-budget-$artifact_prefix-$QA_SUFFIX-2" @@ -108,9 +106,9 @@ run_release_budget_enforcement() { local headers_file="$QA_RUN_DIR/$artifact_prefix.headers" local body_file="$QA_RUN_DIR/$artifact_prefix.body" - curl -fsS -X PUT "$base_url/admin/api/v1/budgets/$encoded_path/daily" \ + curl -fsS -X PUT "$base_url/admin/api/v1/budgets" \ -H 'Content-Type: application/json' \ - -d "{\"amount\":$QA_BUDGET_AMOUNT}" \ + -d "{\"user_path\":\"$budget_path\",\"period\":\"daily\",\"amount\":$QA_BUDGET_AMOUNT}" \ > "$budget_json_file" jq -e --arg user_path "$budget_path" --argjson amount "$QA_BUDGET_AMOUNT" ' any(.budgets[]?; .user_path == $user_path and .period_seconds == 86400 and .amount == $amount and .source == "manual" and .spent == 0) @@ -160,7 +158,9 @@ run_release_budget_enforcement() { any(.budgets[]?; .user_path == $user_path and .last_reset_at != null and .spent == 0 and .has_usage == false) ' "$budget_json_file" >/dev/null - curl -fsS -X DELETE "$base_url/admin/api/v1/budgets/$encoded_path/daily" \ + curl -fsS -X DELETE "$base_url/admin/api/v1/budgets" \ + -H 'Content-Type: application/json' \ + -d "{\"user_path\":\"$budget_path\",\"period\":\"daily\"}" \ > "$budget_json_file" jq -e --arg user_path "$budget_path" ' all(.budgets[]?; .user_path != $user_path) @@ -342,9 +342,9 @@ curl -fsS "$BASE_URL/admin/api/v1/aliases" | jq -e '.' Creates an alias pointing to the newest cheap OpenAI model. ```bash -curl -fsS -X PUT "$BASE_URL/admin/api/v1/aliases/$QA_OPENAI_ALIAS" \ +curl -fsS -X PUT "$BASE_URL/admin/api/v1/aliases" \ -H 'Content-Type: application/json' \ - -d '{"target_model":"gpt-4.1-nano","target_provider":"openai","description":"QA alias for release e2e"}' \ + -d "{\"name\":\"$QA_OPENAI_ALIAS\",\"target_model\":\"gpt-4.1-nano\",\"target_provider\":\"openai\",\"description\":\"QA alias for release e2e\"}" \ | jq -e '.' ``` @@ -353,9 +353,9 @@ curl -fsS -X PUT "$BASE_URL/admin/api/v1/aliases/$QA_OPENAI_ALIAS" \ Creates an alias pointing to `claude-sonnet-4-6`. ```bash -curl -fsS -X PUT "$BASE_URL/admin/api/v1/aliases/$QA_ANTHROPIC_ALIAS" \ +curl -fsS -X PUT "$BASE_URL/admin/api/v1/aliases" \ -H 'Content-Type: application/json' \ - -d '{"target_model":"claude-sonnet-4-6","target_provider":"anthropic","description":"QA alias for anthropic reasoning"}' \ + -d "{\"name\":\"$QA_ANTHROPIC_ALIAS\",\"target_model\":\"claude-sonnet-4-6\",\"target_provider\":\"anthropic\",\"description\":\"QA alias for anthropic reasoning\"}" \ | jq -e '.' ``` @@ -892,7 +892,9 @@ curl -fsS "$GR_BASE_URL/admin/api/v1/usage/summary" | jq -e '.' Removes the per-run OpenAI alias. ```bash -curl -fsS -X DELETE -i "$BASE_URL/admin/api/v1/aliases/$QA_OPENAI_ALIAS" +curl -fsS -X DELETE -i "$BASE_URL/admin/api/v1/aliases" \ + -H 'Content-Type: application/json' \ + -d "{\"name\":\"$QA_OPENAI_ALIAS\"}" ``` ### S60 Delete Anthropic alias @@ -900,7 +902,9 @@ curl -fsS -X DELETE -i "$BASE_URL/admin/api/v1/aliases/$QA_OPENAI_ALIAS" Removes the per-run Anthropic alias. ```bash -curl -fsS -X DELETE -i "$BASE_URL/admin/api/v1/aliases/$QA_ANTHROPIC_ALIAS" +curl -fsS -X DELETE -i "$BASE_URL/admin/api/v1/aliases" \ + -H 'Content-Type: application/json' \ + -d "{\"name\":\"$QA_ANTHROPIC_ALIAS\"}" ``` ## 11. Audit failure coverage @@ -1451,7 +1455,6 @@ Checks budget settings validation, manual budget creation, and deletion on the m ```bash BUDGET_PATH="/team/budget/admin/$QA_SUFFIX" -ENCODED_PATH=$(jq -nr --arg value "$BUDGET_PATH" '$value | @uri') HEADERS_FILE=$(mktemp "$QA_RUN_DIR/s86.headers.XXXXXX") BODY_FILE=$(mktemp "$QA_RUN_DIR/s86.body.XXXXXX") @@ -1468,22 +1471,24 @@ curl -fsS -X PUT "$BASE_URL/admin/api/v1/budgets/settings" \ -d '{"daily_reset_hour":1,"daily_reset_minute":15,"weekly_reset_weekday":2,"monthly_reset_day":2}' \ | jq -e '.daily_reset_hour == 1 and .daily_reset_minute == 15 and .weekly_reset_weekday == 2 and .monthly_reset_day == 2' -curl -sS -D "$HEADERS_FILE" -o "$BODY_FILE" -X PUT "$BASE_URL/admin/api/v1/budgets/$ENCODED_PATH/daily" \ +curl -sS -D "$HEADERS_FILE" -o "$BODY_FILE" -X PUT "$BASE_URL/admin/api/v1/budgets" \ -H 'Content-Type: application/json' \ - -d '{"amount":-1}' + -d "{\"user_path\":\"$BUDGET_PATH\",\"period\":\"daily\",\"amount\":-1}" sed -n '1,20p' "$HEADERS_FILE" jq . "$BODY_FILE" grep -Eiq '^HTTP/.* 400 ' "$HEADERS_FILE" jq -e '.error.type == "invalid_request_error" and (.error.message | test("amount"))' "$BODY_FILE" >/dev/null -curl -fsS -X PUT "$BASE_URL/admin/api/v1/budgets/$ENCODED_PATH/weekly" \ +curl -fsS -X PUT "$BASE_URL/admin/api/v1/budgets" \ -H 'Content-Type: application/json' \ - -d '{"amount":12.5}' \ + -d "{\"user_path\":\"$BUDGET_PATH\",\"period\":\"weekly\",\"amount\":12.5}" \ | jq -e --arg user_path "$BUDGET_PATH" ' any(.budgets[]?; .user_path == $user_path and .period_seconds == 604800 and .amount == 12.5 and .source == "manual") ' >/dev/null -curl -fsS -X DELETE "$BASE_URL/admin/api/v1/budgets/$ENCODED_PATH/weekly" \ +curl -fsS -X DELETE "$BASE_URL/admin/api/v1/budgets" \ + -H 'Content-Type: application/json' \ + -d "{\"user_path\":\"$BUDGET_PATH\",\"period\":\"weekly\"}" \ | jq -e --arg user_path "$BUDGET_PATH" 'all(.budgets[]?; .user_path != $user_path)' >/dev/null curl -fsS -X PUT "$BASE_URL/admin/api/v1/budgets/settings" \ diff --git a/tests/integration/workflows_guardrails_test.go b/tests/integration/workflows_guardrails_test.go index 3fa07000..db534cce 100644 --- a/tests/integration/workflows_guardrails_test.go +++ b/tests/integration/workflows_guardrails_test.go @@ -285,7 +285,13 @@ func createWorkflow(t *testing.T, serverURL, masterKey string, payload map[strin func upsertGuardrail(t *testing.T, serverURL, masterKey, name string, payload map[string]any) guardrails.View { t.Helper() - resp := adminJSONRequest(t, http.MethodPut, serverURL+"/admin/api/v1/guardrails/"+name, masterKey, payload) + body := make(map[string]any, len(payload)+1) + for key, value := range payload { + body[key] = value + } + body["name"] = name + + resp := adminJSONRequest(t, http.MethodPut, serverURL+"/admin/api/v1/guardrails", masterKey, body) defer closeBody(resp) require.Equal(t, http.StatusOK, resp.StatusCode) From 408567a1c28aff8e89059d5242df569ca37c2c1d Mon Sep 17 00:00:00 2001 From: "Jakub A. W" Date: Fri, 8 May 2026 17:12:52 +0200 Subject: [PATCH 2/2] fix(admin): align request schemas with handlers --- cmd/gomodel/docs/docs.go | 57 +++++---- docs/openapi.json | 109 ++++++++++++------ .../dashboard/static/js/modules/budgets.js | 16 ++- .../static/js/modules/budgets.test.cjs | 8 +- internal/admin/handler_aliases.go | 4 +- internal/admin/handler_budgets.go | 40 +++++-- internal/admin/handler_budgets_test.go | 58 +++++++++- internal/admin/handler_guardrails.go | 4 +- internal/admin/handler_model_overrides.go | 4 +- .../admin/handler_model_pricing_overrides.go | 8 +- tests/e2e/budget_test.go | 10 +- tests/e2e/release-e2e-scenarios.md | 10 +- tools/openapi-postprocess.mjs | 16 +++ 13 files changed, 239 insertions(+), 105 deletions(-) diff --git a/cmd/gomodel/docs/docs.go b/cmd/gomodel/docs/docs.go index a4ca2524..4eb44c9f 100644 --- a/cmd/gomodel/docs/docs.go +++ b/cmd/gomodel/docs/docs.go @@ -901,6 +901,12 @@ const docTemplate = `{ "$ref": "#/definitions/core.GatewayError" } }, + "502": { + "description": "Bad Gateway", + "schema": { + "$ref": "#/definitions/core.GatewayError" + } + }, "503": { "description": "Service Unavailable", "schema": { @@ -958,6 +964,12 @@ const docTemplate = `{ "$ref": "#/definitions/core.GatewayError" } }, + "502": { + "description": "Bad Gateway", + "schema": { + "$ref": "#/definitions/core.GatewayError" + } + }, "503": { "description": "Service Unavailable", "schema": { @@ -3562,6 +3574,17 @@ const docTemplate = `{ } } }, + "admin.budgetKeyRequest": { + "type": "object", + "properties": { + "period": { + "type": "string" + }, + "period_seconds": { + "type": "integer" + } + } + }, "admin.budgetListResponse": { "type": "object", "properties": { @@ -3628,15 +3651,9 @@ const docTemplate = `{ }, "admin.deleteBudgetRequest": { "type": "object", - "required": [ - "user_path" - ], "properties": { - "period": { - "type": "string" - }, - "period_seconds": { - "type": "integer" + "budget_key": { + "$ref": "#/definitions/admin.budgetKeyRequest" }, "user_path": { "type": "string" @@ -3645,9 +3662,6 @@ const docTemplate = `{ }, "admin.deleteModelOverrideRequest": { "type": "object", - "required": [ - "selector" - ], "properties": { "selector": { "type": "string" @@ -3656,9 +3670,6 @@ const docTemplate = `{ }, "admin.deleteModelPricingOverrideRequest": { "type": "object", - "required": [ - "selector" - ], "properties": { "selector": { "type": "string" @@ -3798,19 +3809,12 @@ const docTemplate = `{ }, "admin.upsertBudgetRequest": { "type": "object", - "required": [ - "amount", - "user_path" - ], "properties": { "amount": { "type": "number" }, - "period": { - "type": "string" - }, - "period_seconds": { - "type": "integer" + "budget_key": { + "$ref": "#/definitions/admin.budgetKeyRequest" }, "user_path": { "type": "string" @@ -3819,9 +3823,6 @@ const docTemplate = `{ }, "admin.upsertModelOverrideRequest": { "type": "object", - "required": [ - "selector" - ], "properties": { "selector": { "type": "string" @@ -3836,10 +3837,6 @@ const docTemplate = `{ }, "admin.upsertModelPricingOverrideRequest": { "type": "object", - "required": [ - "pricing", - "selector" - ], "properties": { "pricing": { "$ref": "#/definitions/pricingoverrides.Pricing" diff --git a/docs/openapi.json b/docs/openapi.json index 25bbe3da..b122b7ec 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -1104,6 +1104,16 @@ } } }, + "502": { + "description": "Bad Gateway", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/core.GatewayError" + } + } + } + }, "503": { "description": "Service Unavailable", "content": { @@ -1171,6 +1181,16 @@ } } }, + "502": { + "description": "Bad Gateway", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/core.GatewayError" + } + } + } + }, "503": { "description": "Service Unavailable", "content": { @@ -5109,6 +5129,29 @@ } } }, + "admin.budgetKeyRequest": { + "type": "object", + "properties": { + "period": { + "type": "string" + }, + "period_seconds": { + "type": "integer" + } + }, + "oneOf": [ + { + "required": [ + "period" + ] + }, + { + "required": [ + "period_seconds" + ] + } + ] + }, "admin.budgetListResponse": { "type": "object", "properties": { @@ -5175,42 +5218,39 @@ }, "admin.deleteBudgetRequest": { "type": "object", - "required": [ - "user_path" - ], "properties": { - "period": { - "type": "string" - }, - "period_seconds": { - "type": "integer" + "budget_key": { + "$ref": "#/components/schemas/admin.budgetKeyRequest" }, "user_path": { "type": "string" } - } + }, + "required": [ + "budget_key" + ] }, "admin.deleteModelOverrideRequest": { "type": "object", - "required": [ - "selector" - ], "properties": { "selector": { "type": "string" } - } + }, + "required": [ + "selector" + ] }, "admin.deleteModelPricingOverrideRequest": { "type": "object", - "required": [ - "selector" - ], "properties": { "selector": { "type": "string" } - } + }, + "required": [ + "selector" + ] }, "admin.modelAccessResponse": { "type": "object", @@ -5348,30 +5388,24 @@ }, "admin.upsertBudgetRequest": { "type": "object", - "required": [ - "amount", - "user_path" - ], "properties": { "amount": { "type": "number" }, - "period": { - "type": "string" - }, - "period_seconds": { - "type": "integer" + "budget_key": { + "$ref": "#/components/schemas/admin.budgetKeyRequest" }, "user_path": { "type": "string" } - } + }, + "required": [ + "amount", + "budget_key" + ] }, "admin.upsertModelOverrideRequest": { "type": "object", - "required": [ - "selector" - ], "properties": { "selector": { "type": "string" @@ -5384,14 +5418,13 @@ }, "maxItems": 100 } - } + }, + "required": [ + "selector" + ] }, "admin.upsertModelPricingOverrideRequest": { "type": "object", - "required": [ - "pricing", - "selector" - ], "properties": { "pricing": { "$ref": "#/components/schemas/pricingoverrides.Pricing" @@ -5399,7 +5432,11 @@ "selector": { "type": "string" } - } + }, + "required": [ + "pricing", + "selector" + ] }, "auditlog.ConversationResult": { "type": "object", diff --git a/internal/admin/dashboard/static/js/modules/budgets.js b/internal/admin/dashboard/static/js/modules/budgets.js index 84a18bbc..e4086a0c 100644 --- a/internal/admin/dashboard/static/js/modules/budgets.js +++ b/internal/admin/dashboard/static/js/modules/budgets.js @@ -437,7 +437,9 @@ method: 'PUT', body: JSON.stringify({ user_path: payload.user_path, - period_seconds: payload.period_seconds, + budget_key: { + period_seconds: payload.period_seconds + }, amount: payload.amount }) }) @@ -446,7 +448,9 @@ headers: this.headers(), body: JSON.stringify({ user_path: payload.user_path, - period_seconds: payload.period_seconds, + budget_key: { + period_seconds: payload.period_seconds + }, amount: payload.amount }) }; @@ -552,7 +556,9 @@ method: 'DELETE', body: JSON.stringify({ user_path: item.user_path, - period_seconds: item.period_seconds + budget_key: { + period_seconds: item.period_seconds + } }) }) : { @@ -560,7 +566,9 @@ headers: this.headers(), body: JSON.stringify({ user_path: item.user_path, - period_seconds: item.period_seconds + budget_key: { + period_seconds: item.period_seconds + } }) }; const res = await fetch('/admin/api/v1/budgets', request); diff --git a/internal/admin/dashboard/static/js/modules/budgets.test.cjs b/internal/admin/dashboard/static/js/modules/budgets.test.cjs index 045a8aad..fca63ef3 100644 --- a/internal/admin/dashboard/static/js/modules/budgets.test.cjs +++ b/internal/admin/dashboard/static/js/modules/budgets.test.cjs @@ -213,7 +213,9 @@ test('confirmBudgetOverride saves the pending create after confirmation', async assert.equal(requests[0].request.method, 'PUT'); assert.equal(requests[0].request.body, JSON.stringify({ user_path: '/team', - period_seconds: 86400, + budget_key: { + period_seconds: 86400 + }, amount: 12.5 })); assert.equal(requests[1].url, '/admin/api/v1/budgets'); @@ -344,7 +346,9 @@ test('deleteBudget sends the selected budget key in the body and refreshes from assert.equal(requests[0].request.method, 'DELETE'); assert.equal(requests[0].request.body, JSON.stringify({ user_path: '/team', - period_seconds: 86400 + budget_key: { + period_seconds: 86400 + } })); assert.equal(module.budgetDeletingKey, ''); assert.equal(module.budgetNotice, 'Budget deleted.'); diff --git a/internal/admin/handler_aliases.go b/internal/admin/handler_aliases.go index cbb79ebd..58aac4de 100644 --- a/internal/admin/handler_aliases.go +++ b/internal/admin/handler_aliases.go @@ -12,7 +12,7 @@ import ( ) type upsertAliasRequest struct { - Name string `json:"name" binding:"required"` + Name string `json:"name"` TargetModel string `json:"target_model"` TargetProvider string `json:"target_provider,omitempty"` Description string `json:"description,omitempty"` @@ -20,7 +20,7 @@ type upsertAliasRequest struct { } type deleteAliasRequest struct { - Name string `json:"name" binding:"required"` + Name string `json:"name"` } func (h *Handler) ListAliases(c *echo.Context) error { diff --git a/internal/admin/handler_budgets.go b/internal/admin/handler_budgets.go index 9392054c..9cf80ae4 100644 --- a/internal/admin/handler_budgets.go +++ b/internal/admin/handler_budgets.go @@ -58,7 +58,7 @@ func (h *Handler) UpsertBudget(c *echo.Context) error { if err := c.Bind(&req); err != nil { return handleError(c, core.NewInvalidRequestError("invalid request body: "+err.Error(), err)) } - userPath, periodSeconds, err := budgetRequestKey(req.UserPath, req.Period, req.PeriodSeconds) + userPath, periodSeconds, err := budgetRequestKey(req.UserPath, req.BudgetKey) if err != nil { return handleError(c, core.NewInvalidRequestError(err.Error(), err)) } @@ -97,7 +97,7 @@ func (h *Handler) DeleteBudget(c *echo.Context) error { if err := c.Bind(&req); err != nil { return handleError(c, core.NewInvalidRequestError("invalid request body: "+err.Error(), err)) } - userPath, periodSeconds, err := budgetRequestKey(req.UserPath, req.Period, req.PeriodSeconds) + userPath, periodSeconds, err := budgetRequestKey(req.UserPath, req.BudgetKey) if err != nil { return handleError(c, core.NewInvalidRequestError(err.Error(), err)) } @@ -241,14 +241,17 @@ type budgetStatusResponse struct { } type upsertBudgetRequest struct { - UserPath string `json:"user_path" binding:"required"` - Period string `json:"period,omitempty"` - PeriodSeconds int64 `json:"period_seconds,omitempty"` - Amount float64 `json:"amount" binding:"required"` + UserPath string `json:"user_path"` + BudgetKey *budgetKeyRequest `json:"budget_key"` + Amount float64 `json:"amount"` } type deleteBudgetRequest struct { - UserPath string `json:"user_path" binding:"required"` + UserPath string `json:"user_path"` + BudgetKey *budgetKeyRequest `json:"budget_key"` +} + +type budgetKeyRequest struct { Period string `json:"period,omitempty"` PeriodSeconds int64 `json:"period_seconds,omitempty"` } @@ -351,18 +354,37 @@ func budgetStatusResponses(statuses []budget.CheckResult, now time.Time) []budge return responses } -func budgetRequestKey(rawUserPath, period string, periodSeconds int64) (string, int64, error) { +func budgetRequestKey(rawUserPath string, key *budgetKeyRequest) (string, int64, error) { userPath, err := budget.NormalizeUserPath(rawUserPath) if err != nil { return "", 0, err } - periodSeconds, err = budgetRequestPeriodSeconds(period, periodSeconds) + periodSeconds, err := budgetKeyPeriodSeconds(key) if err != nil { return "", 0, err } return userPath, periodSeconds, nil } +func budgetKeyPeriodSeconds(key *budgetKeyRequest) (int64, error) { + if key == nil { + return 0, errors.New("budget_key is required") + } + period := strings.TrimSpace(key.Period) + hasPeriod := period != "" + hasPeriodSeconds := key.PeriodSeconds != 0 + if !hasPeriod && !hasPeriodSeconds { + return 0, errors.New("budget_key.period or budget_key.period_seconds is required") + } + if hasPeriod && hasPeriodSeconds { + return 0, errors.New("set either budget_key.period or budget_key.period_seconds, not both") + } + if key.PeriodSeconds < 0 { + return 0, errors.New("budget_key.period_seconds must be greater than 0") + } + return budgetRequestPeriodSeconds(period, key.PeriodSeconds) +} + func budgetRequestPeriodSeconds(period string, periodSeconds int64) (int64, error) { if periodSeconds > 0 { return periodSeconds, nil diff --git a/internal/admin/handler_budgets_test.go b/internal/admin/handler_budgets_test.go index 8c6684b1..18e7858c 100644 --- a/internal/admin/handler_budgets_test.go +++ b/internal/admin/handler_budgets_test.go @@ -165,7 +165,7 @@ func TestBudgetEndpointsUpsertAndResetOneBudget(t *testing.T) { upsertReq := httptest.NewRequest( http.MethodPut, "/admin/api/v1/budgets", - strings.NewReader(`{"user_path":"/team/beta","period":"weekly","amount":12.5}`), + strings.NewReader(`{"user_path":"/team/beta","budget_key":{"period":"weekly"},"amount":12.5}`), ) upsertReq.Header.Set("Content-Type", "application/json") upsertRec := httptest.NewRecorder() @@ -210,7 +210,7 @@ func TestBudgetEndpointsUpsertMarksConfigBudgetManual(t *testing.T) { req := httptest.NewRequest( http.MethodPut, "/admin/api/v1/budgets", - strings.NewReader(`{"user_path":"/team","period":"daily","amount":12.5}`), + strings.NewReader(`{"user_path":"/team","budget_key":{"period":"daily"},"amount":12.5}`), ) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() @@ -237,7 +237,7 @@ func TestBudgetEndpointsUpsertAcceptsNumericPeriodString(t *testing.T) { req := httptest.NewRequest( http.MethodPut, "/admin/api/v1/budgets", - strings.NewReader(`{"user_path":"/team","period":"604800","amount":12.5}`), + strings.NewReader(`{"user_path":"/team","budget_key":{"period":"604800"},"amount":12.5}`), ) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() @@ -254,6 +254,54 @@ func TestBudgetEndpointsUpsertAcceptsNumericPeriodString(t *testing.T) { } } +func TestBudgetEndpointsRejectInvalidBudgetKey(t *testing.T) { + tests := []struct { + name string + body string + run func(*Handler, *echo.Context) error + }{ + { + name: "missing budget key", + body: `{"user_path":"/team","amount":12.5}`, + run: (*Handler).UpsertBudget, + }, + { + name: "empty budget key", + body: `{"user_path":"/team","budget_key":{},"amount":12.5}`, + run: (*Handler).UpsertBudget, + }, + { + name: "ambiguous budget key", + body: `{"user_path":"/team","budget_key":{"period":"daily","period_seconds":86400},"amount":12.5}`, + run: (*Handler).UpsertBudget, + }, + { + name: "delete missing budget key", + body: `{"user_path":"/team"}`, + run: (*Handler).DeleteBudget, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + store := &adminBudgetStore{} + h := newBudgetHandler(t, store) + e := echo.New() + req := httptest.NewRequest(http.MethodPut, "/admin/api/v1/budgets", strings.NewReader(tt.body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + + if err := tt.run(h, c); err != nil { + t.Fatalf("handler failed: %v", err) + } + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want %d body=%s", rec.Code, http.StatusBadRequest, rec.Body.String()) + } + }) + } +} + func TestBudgetEndpointsDeleteBudget(t *testing.T) { store := &adminBudgetStore{ budgets: []budget.Budget{ @@ -266,7 +314,7 @@ func TestBudgetEndpointsDeleteBudget(t *testing.T) { req := httptest.NewRequest( http.MethodDelete, "/admin/api/v1/budgets", - strings.NewReader(`{"user_path":"/team/beta","period_seconds":604800}`), + strings.NewReader(`{"user_path":"/team/beta","budget_key":{"period_seconds":604800}}`), ) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() @@ -295,7 +343,7 @@ func TestBudgetEndpointsMissingMutationsReturnNotFound(t *testing.T) { store.deleteErr = budget.ErrNotFound }, run: func(h *Handler, e *echo.Echo) *httptest.ResponseRecorder { - req := httptest.NewRequest(http.MethodDelete, "/admin/api/v1/budgets", strings.NewReader(`{"user_path":"/team","period_seconds":86400}`)) + req := httptest.NewRequest(http.MethodDelete, "/admin/api/v1/budgets", strings.NewReader(`{"user_path":"/team","budget_key":{"period_seconds":86400}}`)) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() c := e.NewContext(req, rec) diff --git a/internal/admin/handler_guardrails.go b/internal/admin/handler_guardrails.go index fa2d3c89..2cc0c5e2 100644 --- a/internal/admin/handler_guardrails.go +++ b/internal/admin/handler_guardrails.go @@ -13,7 +13,7 @@ import ( ) type upsertGuardrailRequest struct { - Name string `json:"name" binding:"required"` + Name string `json:"name"` Type string `json:"type"` Description string `json:"description,omitempty"` UserPath string `json:"user_path,omitempty"` @@ -21,7 +21,7 @@ type upsertGuardrailRequest struct { } type deleteGuardrailRequest struct { - Name string `json:"name" binding:"required"` + Name string `json:"name"` } func (h *Handler) ListGuardrailTypes(c *echo.Context) error { diff --git a/internal/admin/handler_model_overrides.go b/internal/admin/handler_model_overrides.go index 528ab319..ed43b85b 100644 --- a/internal/admin/handler_model_overrides.go +++ b/internal/admin/handler_model_overrides.go @@ -12,12 +12,12 @@ import ( ) type upsertModelOverrideRequest struct { - Selector string `json:"selector" binding:"required"` + Selector string `json:"selector"` UserPaths []string `json:"user_paths,omitempty"` } type deleteModelOverrideRequest struct { - Selector string `json:"selector" binding:"required"` + Selector string `json:"selector"` } // ListModelOverrides handles GET /admin/api/v1/model-overrides. diff --git a/internal/admin/handler_model_pricing_overrides.go b/internal/admin/handler_model_pricing_overrides.go index 9ac968cf..8caa36aa 100644 --- a/internal/admin/handler_model_pricing_overrides.go +++ b/internal/admin/handler_model_pricing_overrides.go @@ -12,12 +12,12 @@ import ( ) type upsertModelPricingOverrideRequest struct { - Selector string `json:"selector" binding:"required"` - Pricing pricingoverrides.Pricing `json:"pricing" binding:"required"` + Selector string `json:"selector"` + Pricing pricingoverrides.Pricing `json:"pricing"` } type deleteModelPricingOverrideRequest struct { - Selector string `json:"selector" binding:"required"` + Selector string `json:"selector"` } // ListModelPricingOverrides handles GET /admin/api/v1/model-pricing-overrides. @@ -55,6 +55,7 @@ func (h *Handler) ListModelPricingOverrides(c *echo.Context) error { // @Failure 400 {object} core.GatewayError // @Failure 401 {object} core.GatewayError // @Failure 500 {object} core.GatewayError +// @Failure 502 {object} core.GatewayError // @Failure 503 {object} core.GatewayError // @Router /admin/api/v1/model-pricing-overrides [put] // @@ -100,6 +101,7 @@ func (h *Handler) UpsertModelPricingOverride(c *echo.Context) error { // @Failure 400 {object} core.GatewayError // @Failure 401 {object} core.GatewayError // @Failure 404 {object} core.GatewayError +// @Failure 502 {object} core.GatewayError // @Failure 503 {object} core.GatewayError // @Router /admin/api/v1/model-pricing-overrides [delete] // diff --git a/tests/e2e/budget_test.go b/tests/e2e/budget_test.go index dc69cca3..129570d8 100644 --- a/tests/e2e/budget_test.go +++ b/tests/e2e/budget_test.go @@ -88,9 +88,9 @@ func TestBudgetAdminEndpointsSQLite_E2E(t *testing.T) { defer ts.Close() putResp := sendBudgetJSONRequest(t, http.MethodPut, ts.URL+"/admin/api/v1/budgets", map[string]any{ - "user_path": "/team/admin", - "period": "daily", - "amount": 12.5, + "user_path": "/team/admin", + "budget_key": map[string]any{"period": "daily"}, + "amount": 12.5, }) require.Equal(t, http.StatusOK, putResp.StatusCode) closeBody(putResp) @@ -124,8 +124,8 @@ func TestBudgetAdminEndpointsSQLite_E2E(t *testing.T) { require.NotNil(t, statuses[0].Budget.LastResetAt) deleteResp := sendBudgetJSONRequest(t, http.MethodDelete, ts.URL+"/admin/api/v1/budgets", map[string]any{ - "user_path": "/team/admin", - "period": "daily", + "user_path": "/team/admin", + "budget_key": map[string]any{"period": "daily"}, }) require.Equal(t, http.StatusOK, deleteResp.StatusCode) closeBody(deleteResp) diff --git a/tests/e2e/release-e2e-scenarios.md b/tests/e2e/release-e2e-scenarios.md index 4c6c489d..4b368acb 100644 --- a/tests/e2e/release-e2e-scenarios.md +++ b/tests/e2e/release-e2e-scenarios.md @@ -108,7 +108,7 @@ run_release_budget_enforcement() { curl -fsS -X PUT "$base_url/admin/api/v1/budgets" \ -H 'Content-Type: application/json' \ - -d "{\"user_path\":\"$budget_path\",\"period\":\"daily\",\"amount\":$QA_BUDGET_AMOUNT}" \ + -d "{\"user_path\":\"$budget_path\",\"budget_key\":{\"period\":\"daily\"},\"amount\":$QA_BUDGET_AMOUNT}" \ > "$budget_json_file" jq -e --arg user_path "$budget_path" --argjson amount "$QA_BUDGET_AMOUNT" ' any(.budgets[]?; .user_path == $user_path and .period_seconds == 86400 and .amount == $amount and .source == "manual" and .spent == 0) @@ -160,7 +160,7 @@ run_release_budget_enforcement() { curl -fsS -X DELETE "$base_url/admin/api/v1/budgets" \ -H 'Content-Type: application/json' \ - -d "{\"user_path\":\"$budget_path\",\"period\":\"daily\"}" \ + -d "{\"user_path\":\"$budget_path\",\"budget_key\":{\"period\":\"daily\"}}" \ > "$budget_json_file" jq -e --arg user_path "$budget_path" ' all(.budgets[]?; .user_path != $user_path) @@ -1473,7 +1473,7 @@ curl -fsS -X PUT "$BASE_URL/admin/api/v1/budgets/settings" \ curl -sS -D "$HEADERS_FILE" -o "$BODY_FILE" -X PUT "$BASE_URL/admin/api/v1/budgets" \ -H 'Content-Type: application/json' \ - -d "{\"user_path\":\"$BUDGET_PATH\",\"period\":\"daily\",\"amount\":-1}" + -d "{\"user_path\":\"$BUDGET_PATH\",\"budget_key\":{\"period\":\"daily\"},\"amount\":-1}" sed -n '1,20p' "$HEADERS_FILE" jq . "$BODY_FILE" grep -Eiq '^HTTP/.* 400 ' "$HEADERS_FILE" @@ -1481,14 +1481,14 @@ jq -e '.error.type == "invalid_request_error" and (.error.message | test("amount curl -fsS -X PUT "$BASE_URL/admin/api/v1/budgets" \ -H 'Content-Type: application/json' \ - -d "{\"user_path\":\"$BUDGET_PATH\",\"period\":\"weekly\",\"amount\":12.5}" \ + -d "{\"user_path\":\"$BUDGET_PATH\",\"budget_key\":{\"period\":\"weekly\"},\"amount\":12.5}" \ | jq -e --arg user_path "$BUDGET_PATH" ' any(.budgets[]?; .user_path == $user_path and .period_seconds == 604800 and .amount == 12.5 and .source == "manual") ' >/dev/null curl -fsS -X DELETE "$BASE_URL/admin/api/v1/budgets" \ -H 'Content-Type: application/json' \ - -d "{\"user_path\":\"$BUDGET_PATH\",\"period\":\"weekly\"}" \ + -d "{\"user_path\":\"$BUDGET_PATH\",\"budget_key\":{\"period\":\"weekly\"}}" \ | jq -e --arg user_path "$BUDGET_PATH" 'all(.budgets[]?; .user_path != $user_path)' >/dev/null curl -fsS -X PUT "$BASE_URL/admin/api/v1/budgets/settings" \ diff --git a/tools/openapi-postprocess.mjs b/tools/openapi-postprocess.mjs index 18c630a5..8ce44bb6 100644 --- a/tools/openapi-postprocess.mjs +++ b/tools/openapi-postprocess.mjs @@ -170,11 +170,27 @@ function applyPricingSchemaConstraints() { } } +function applyBudgetKeySchemaConstraints() { + const target = schema("admin.budgetKeyRequest"); + if (!target.properties?.period || !target.properties?.period_seconds) { + throw new Error("missing budget key period properties"); + } + target.oneOf = [{ required: ["period"] }, { required: ["period_seconds"] }]; +} + spec.servers = parseServers(process.env.DOCS_API_SERVERS); ensureResponsesInputElementSchema(); ensureBearerAuthSecurityScheme(); ensureRequiredProperty("admin.recalculatePricingRequest", "confirmation"); +ensureRequiredProperty("admin.upsertBudgetRequest", "amount"); +ensureRequiredProperty("admin.upsertBudgetRequest", "budget_key"); +ensureRequiredProperty("admin.deleteBudgetRequest", "budget_key"); +ensureRequiredProperty("admin.upsertModelOverrideRequest", "selector"); +ensureRequiredProperty("admin.deleteModelOverrideRequest", "selector"); +ensureRequiredProperty("admin.upsertModelPricingOverrideRequest", "selector"); ensureRequiredProperty("admin.upsertModelPricingOverrideRequest", "pricing"); +ensureRequiredProperty("admin.deleteModelPricingOverrideRequest", "selector"); +applyBudgetKeySchemaConstraints(); applyStringArrayPropertyBounds("admin.upsertModelOverrideRequest", "user_paths", 100, 1024); applyPricingSchemaConstraints();