From bc04271a5ce8787028285477a7471875581377e7 Mon Sep 17 00:00:00 2001 From: "Jakub A. W" Date: Wed, 1 Apr 2026 13:06:59 +0200 Subject: [PATCH 1/5] fix(release): adapt gpt-5 chat params and batch aliases --- docs/advanced/configuration.mdx | 6 + docs/docs.json | 4 + docs/features/cache.mdx | 96 ++++++++++ docs/getting-started/quickstart.mdx | 1 + internal/aliases/batch_preparer_test.go | 37 ++++ internal/aliases/service.go | 20 +- internal/aliases/service_test.go | 29 +++ internal/providers/openai/openai.go | 27 ++- internal/providers/openai/openai_test.go | 147 +++++++++++++++ internal/server/handlers_test.go | 53 ++++++ tests/e2e/release-e2e-scenarios.md | 231 ++++++++++++++++++++++- 11 files changed, 636 insertions(+), 15 deletions(-) create mode 100644 docs/features/cache.mdx diff --git a/docs/advanced/configuration.mdx b/docs/advanced/configuration.mdx index cf019a8a..7cdf0095 100644 --- a/docs/advanced/configuration.mdx +++ b/docs/advanced/configuration.mdx @@ -55,6 +55,12 @@ The most common way to configure GOModel. Set any of the variables below to over | `REDIS_TTL_MODELS` | TTL in seconds for model cache | `86400` (24h) | | `REDIS_TTL_RESPONSES` | TTL in seconds for response cache | `3600` (1h) | + + See [Cache](/features/cache) for exact-cache behavior, response headers, + analytics endpoints, and the note that `user_path` alone does not partition + the exact cache. + + #### Storage Storage is shared by audit logging, usage tracking, and future features like IAM. diff --git a/docs/docs.json b/docs/docs.json index 02b58e83..8a202958 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -15,6 +15,10 @@ "group": "Getting Started", "pages": ["getting-started/quickstart"] }, + { + "group": "Features", + "pages": ["features/cache"] + }, { "group": "Guides", "pages": [ diff --git a/docs/features/cache.mdx b/docs/features/cache.mdx new file mode 100644 index 00000000..84b24307 --- /dev/null +++ b/docs/features/cache.mdx @@ -0,0 +1,96 @@ +--- +title: "Cache" +description: "How GOModel response caching works, how to enable it, and what is included in the exact-cache key." +--- + +## Overview + +GOModel ships with an exact-match response cache for non-streaming requests on: + +- `/v1/chat/completions` +- `/v1/responses` +- `/v1/embeddings` + +When a response is served from the exact cache, GOModel returns: + +```http +X-Cache: HIT (exact) +``` + +Semantic caching is planned separately. The exact layer is the cache behavior +available today. + +## Enable the exact cache + +Point response caching at Redis: + +```yaml +cache: + response: + simple: + redis: + url: redis://localhost:6379 + ttl: 3600 +``` + +You can also configure it with environment variables: + +- `REDIS_URL` +- `REDIS_KEY_RESPONSES` +- `REDIS_TTL_RESPONSES` + +## What the exact cache keys on + +The exact cache hashes: + +- the request path +- the resolved execution plan context used for execution + specifically execution mode, provider type, and resolved model +- the final request body + +This means guardrails and workflows affect cache keys when they change the +resolved execution plan or the final body sent through execution. + +You can bypass caching per request with: + +```http +Cache-Control: no-cache +``` + +or: + +```http +Cache-Control: no-store +``` + +## `user_path` behavior + +`user_path` is not added to the exact-cache key by itself. + +That is intentional. If two requests end up with the same path, resolved +execution plan, and final request body, they can share the same exact-cache +entry even when they originate from different `user_path` values. + + + If you need tenant or path-specific cache behavior, use a scoped workflow or + otherwise make the final request differ for that scope. `user_path` alone is + not an exact-cache partition key. + + +Common patterns: + +- disable cache in a scoped workflow +- use different scoped workflows for different `user_path` values +- include scope-specific context so the final request body differs + +## Cache analytics + +When response caching and usage tracking are enabled, the admin API exposes a +cached-only overview at: + +```text +/admin/api/v1/cache/overview +``` + +Cached usage entries are also visible in the regular usage log and summary +endpoints. diff --git a/docs/getting-started/quickstart.mdx b/docs/getting-started/quickstart.mdx index 882beb84..0a32e78a 100644 --- a/docs/getting-started/quickstart.mdx +++ b/docs/getting-started/quickstart.mdx @@ -79,6 +79,7 @@ Use one of those model IDs in your requests. ## Next Steps +- Understand response caching: [Cache](/features/cache) - Configure production settings: [Configuration](/advanced/configuration) - Add request policies: [Guardrails](/advanced/guardrails) - Connect OpenClaw: [Using GOModel with OpenClaw](/guides/openclaw) diff --git a/internal/aliases/batch_preparer_test.go b/internal/aliases/batch_preparer_test.go index 81ad6c71..648e10f5 100644 --- a/internal/aliases/batch_preparer_test.go +++ b/internal/aliases/batch_preparer_test.go @@ -93,3 +93,40 @@ func TestBatchPreparerRejectsAliasResolvedToDifferentProvider(t *testing.T) { t.Fatalf("len(fileCreates) = %d, want 0", len(inner.fileCreates)) } } + +func TestBatchPreparerRejectsAliasResolvedToDifferentProviderWithExplicitProviderField(t *testing.T) { + catalog := newTestCatalog() + catalog.add("anthropic/claude-3-7-sonnet", "anthropic", core.Model{ID: "claude-3-7-sonnet", Object: "model"}) + + service, err := NewService(newMemoryStore(Alias{Name: "smart", TargetModel: "claude-3-7-sonnet", TargetProvider: "anthropic", Enabled: true}), catalog) + if err != nil { + t.Fatalf("NewService() error = %v", err) + } + if err := service.Refresh(context.Background()); err != nil { + t.Fatalf("Refresh() error = %v", err) + } + + inner := newProviderMock() + inner.supported["anthropic/claude-3-7-sonnet"] = true + inner.providerType["anthropic/claude-3-7-sonnet"] = "anthropic" + inner.fileContent = &core.FileContentResponse{ + ID: "file_source", + Filename: "batch.jsonl", + Data: []byte("{\"custom_id\":\"1\",\"method\":\"POST\",\"url\":\"/v1/chat/completions\",\"body\":{\"model\":\"smart\",\"provider\":\"openai\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}}\n"), + } + + preparer := NewBatchPreparer(inner, service) + _, err = preparer.PrepareBatchRequest(context.Background(), "openai", &core.BatchRequest{ + InputFileID: "file_source", + Endpoint: "/v1/chat/completions", + }) + if err == nil { + t.Fatal("PrepareBatchRequest() error = nil, want provider mismatch") + } + if !strings.Contains(err.Error(), `native batch supports a single provider per batch`) { + t.Fatalf("PrepareBatchRequest() error = %v, want mixed-provider validation error", err) + } + if len(inner.fileCreates) != 0 { + t.Fatalf("len(fileCreates) = %d, want 0", len(inner.fileCreates)) + } +} diff --git a/internal/aliases/service.go b/internal/aliases/service.go index d0d6b0ed..d537e88d 100644 --- a/internal/aliases/service.go +++ b/internal/aliases/service.go @@ -122,16 +122,24 @@ func (s *Service) Resolve(model, provider string) (Resolution, bool, error) { } func (s *Service) resolveRequested(requested core.RequestedModelSelector) (Resolution, bool, error) { - if !requested.ExplicitProvider { - if resolution, ok := s.resolveAlias(requested.Model); ok { - return resolution, true, nil - } - } - selector, err := requested.Normalize() if err != nil { return Resolution{}, false, err } + + if requested.ExplicitProvider { + if !s.catalog.Supports(selector.QualifiedModel()) { + if resolution, ok := s.resolveAlias(requested.Model); ok { + resolution.Requested = selector + return resolution, true, nil + } + } + return Resolution{Requested: selector, Resolved: selector}, false, nil + } + + if resolution, ok := s.resolveAlias(requested.Model); ok { + return resolution, true, nil + } return Resolution{Requested: selector, Resolved: selector}, false, nil } diff --git a/internal/aliases/service_test.go b/internal/aliases/service_test.go index 514833bf..e8055b28 100644 --- a/internal/aliases/service_test.go +++ b/internal/aliases/service_test.go @@ -235,6 +235,35 @@ func TestServiceResolveAliasWithExplicitProviderAndSlashModel(t *testing.T) { } } +func TestServiceResolveAliasWithExplicitProviderFallbackWhenConcreteSelectorMissing(t *testing.T) { + catalog := newTestCatalog() + catalog.add("anthropic/claude-3-7-sonnet", "anthropic", core.Model{ID: "claude-3-7-sonnet", Object: "model"}) + + service, err := NewService(newMemoryStore(Alias{ + Name: "smart", + TargetModel: "claude-3-7-sonnet", + TargetProvider: "anthropic", + Enabled: true, + }), catalog) + if err != nil { + t.Fatalf("NewService() error = %v", err) + } + if err := service.Refresh(context.Background()); err != nil { + t.Fatalf("Refresh() error = %v", err) + } + + selector, changed, err := service.ResolveModel(core.NewRequestedModelSelector("smart", "openai")) + if err != nil { + t.Fatalf("ResolveModel() error = %v", err) + } + if !changed { + t.Fatal("ResolveModel() changed = false, want true") + } + if got := selector.QualifiedModel(); got != "anthropic/claude-3-7-sonnet" { + t.Fatalf("resolved selector = %q, want anthropic/claude-3-7-sonnet", got) + } +} + func TestServiceUpsertRejectsQualifiedAliasChainsAndSelfTargets(t *testing.T) { catalog := newTestCatalog() catalog.add("gpt-4o", "openai", core.Model{ID: "gpt-4o", Object: "model"}) diff --git a/internal/providers/openai/openai.go b/internal/providers/openai/openai.go index 4033a5ee..bae594ef 100644 --- a/internal/providers/openai/openai.go +++ b/internal/providers/openai/openai.go @@ -89,18 +89,29 @@ func isOSeriesModel(model string) bool { return len(m) >= 2 && m[0] == 'o' && m[1] >= '0' && m[1] <= '9' } -// adaptForOSeries rewrites a ChatRequest body for OpenAI o-series models, -// mapping max_tokens -> max_completion_tokens and dropping temperature while -// preserving all unknown top-level JSON fields. -func adaptForOSeries(req *core.ChatRequest) (any, error) { +func isGPT5Model(model string) bool { + m := strings.ToLower(strings.TrimSpace(model)) + return m == "gpt-5" || strings.HasPrefix(m, "gpt-5-") +} + +// isReasoningChatModel reports whether the model follows OpenAI's reasoning +// chat parameter rules for max_completion_tokens and temperature handling. +func isReasoningChatModel(model string) bool { + return isOSeriesModel(model) || isGPT5Model(model) +} + +// adaptForReasoningChat rewrites a ChatRequest body for OpenAI reasoning chat +// models, mapping max_tokens -> max_completion_tokens and dropping temperature +// while preserving all unknown top-level JSON fields. +func adaptForReasoningChat(req *core.ChatRequest) (any, error) { body, err := json.Marshal(req) if err != nil { - return nil, core.NewInvalidRequestError("failed to marshal o-series request: "+err.Error(), err) + return nil, core.NewInvalidRequestError("failed to marshal reasoning request: "+err.Error(), err) } var raw map[string]json.RawMessage if err := json.Unmarshal(body, &raw); err != nil { - return nil, core.NewInvalidRequestError("failed to decode o-series request payload: "+err.Error(), err) + return nil, core.NewInvalidRequestError("failed to decode reasoning request payload: "+err.Error(), err) } if maxTokens, ok := raw["max_tokens"]; ok { raw["max_completion_tokens"] = maxTokens @@ -113,8 +124,8 @@ func adaptForOSeries(req *core.ChatRequest) (any, error) { // chatRequestBody returns the appropriate request body for the model. // Reasoning models get parameter adaptation; others pass through as-is. func chatRequestBody(req *core.ChatRequest) (any, error) { - if isOSeriesModel(req.Model) { - return adaptForOSeries(req) + if isReasoningChatModel(req.Model) { + return adaptForReasoningChat(req) } return req, nil } diff --git a/internal/providers/openai/openai_test.go b/internal/providers/openai/openai_test.go index 4df6d65c..4c48480e 100644 --- a/internal/providers/openai/openai_test.go +++ b/internal/providers/openai/openai_test.go @@ -1472,6 +1472,31 @@ func TestIsOSeriesModel(t *testing.T) { } } +func TestIsReasoningChatModel(t *testing.T) { + tests := []struct { + model string + expected bool + }{ + {"o3-mini", true}, + {"o4-mini", true}, + {"gpt-5", true}, + {"gpt-5-mini", true}, + {"gpt-5-nano", true}, + {"gpt-5-chat-latest", true}, + {"gpt-4o", false}, + {"gpt-4.1", false}, + {"claude-sonnet-4-6", false}, + {"", false}, + } + for _, tt := range tests { + t.Run(tt.model, func(t *testing.T) { + if got := isReasoningChatModel(tt.model); got != tt.expected { + t.Errorf("isReasoningChatModel(%q) = %v, want %v", tt.model, got, tt.expected) + } + }) + } +} + func TestChatCompletion_ReasoningModel_AdaptsParameters(t *testing.T) { maxTokens := 1000 @@ -1536,6 +1561,67 @@ func TestChatCompletion_ReasoningModel_AdaptsParameters(t *testing.T) { } } +func TestChatCompletion_GPT5Model_AdaptsParameters(t *testing.T) { + maxTokens := 1000 + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + if err != nil { + t.Fatalf("failed to read request body: %v", err) + } + + var raw map[string]any + if err := json.Unmarshal(body, &raw); err != nil { + t.Fatalf("failed to unmarshal request: %v", err) + } + + if _, ok := raw["max_tokens"]; ok { + t.Error("gpt-5 request should not contain max_tokens") + } + + mct, ok := raw["max_completion_tokens"] + if !ok { + t.Fatal("gpt-5 request should contain max_completion_tokens") + } + if int(mct.(float64)) != maxTokens { + t.Errorf("max_completion_tokens = %v, want %d", mct, maxTokens) + } + + if _, ok := raw["temperature"]; ok { + t.Error("gpt-5 request should not contain temperature") + } + + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{ + "id": "chatcmpl-gpt5", + "object": "chat.completion", + "model": "gpt-5-mini", + "choices": [{"index": 0, "message": {"role": "assistant", "content": "Hi"}, "finish_reason": "stop"}], + "usage": {"prompt_tokens": 5, "completion_tokens": 10, "total_tokens": 15} + }`)) + })) + defer server.Close() + + provider := NewWithHTTPClient("test-api-key", nil, llmclient.Hooks{}) + provider.SetBaseURL(server.URL) + + temp := 0.7 + req := &core.ChatRequest{ + Model: "gpt-5-mini", + Messages: []core.Message{{Role: "user", Content: "Hello"}}, + MaxTokens: &maxTokens, + Temperature: &temp, + } + + resp, err := provider.ChatCompletion(context.Background(), req) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if resp.Model != "gpt-5-mini" { + t.Errorf("Model = %q, want %q", resp.Model, "gpt-5-mini") + } +} + func TestChatCompletion_NonReasoningModel_PassesMaxTokens(t *testing.T) { maxTokens := 1000 @@ -1746,6 +1832,67 @@ data: [DONE] } } +func TestStreamChatCompletion_GPT5Model_AdaptsParameters(t *testing.T) { + maxTokens := 2000 + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + if err != nil { + t.Fatalf("failed to read request body: %v", err) + } + + var raw map[string]any + if err := json.Unmarshal(body, &raw); err != nil { + t.Fatalf("failed to unmarshal request: %v", err) + } + + if _, ok := raw["max_tokens"]; ok { + t.Error("streaming gpt-5 request should not contain max_tokens") + } + mct, ok := raw["max_completion_tokens"] + if !ok { + t.Fatal("streaming gpt-5 request should contain max_completion_tokens") + } + if int(mct.(float64)) != maxTokens { + t.Errorf("max_completion_tokens = %v, want %d", mct, maxTokens) + } + + if stream, ok := raw["stream"].(bool); !ok || !stream { + t.Error("stream should be true") + } + + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`data: {"id":"chatcmpl-gpt5","object":"chat.completion.chunk","model":"gpt-5-nano","choices":[{"index":0,"delta":{"content":"Hi"},"finish_reason":null}]} + +data: [DONE] +`)) + })) + defer server.Close() + + provider := NewWithHTTPClient("test-api-key", nil, llmclient.Hooks{}) + provider.SetBaseURL(server.URL) + + req := &core.ChatRequest{ + Model: "gpt-5-nano", + Messages: []core.Message{{Role: "user", Content: "Hello"}}, + MaxTokens: &maxTokens, + } + + body, err := provider.StreamChatCompletion(context.Background(), req) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + defer func() { _ = body.Close() }() + + respBody, err := io.ReadAll(body) + if err != nil { + t.Fatalf("failed to read response body: %v", err) + } + if !strings.Contains(string(respBody), "gpt-5-nano") { + t.Error("response should contain gpt-5-nano model") + } +} + func TestChatCompletion_ReasoningModel_PreservesToolConfiguration(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { body, err := io.ReadAll(r.Body) diff --git a/internal/server/handlers_test.go b/internal/server/handlers_test.go index a824d669..1e45a83b 100644 --- a/internal/server/handlers_test.go +++ b/internal/server/handlers_test.go @@ -3435,6 +3435,59 @@ func TestBatches_InputFileRewritesAliasesAndPersistsBatchPreparation(t *testing. require.Equal(t, "file_rewritten", stored.RewrittenInputFileID) } +func TestBatches_InputFileRejectsExplicitProviderAliasMismatch(t *testing.T) { + store := newAliasesTestStore(aliases.Alias{Name: "smart", TargetModel: "claude-3-7-sonnet", TargetProvider: "anthropic", Enabled: true}) + catalog := &aliasesTestCatalog{ + supported: map[string]bool{ + "anthropic/claude-3-7-sonnet": true, + }, + providerTypes: map[string]string{ + "anthropic/claude-3-7-sonnet": "anthropic", + }, + models: map[string]core.Model{ + "anthropic/claude-3-7-sonnet": {ID: "claude-3-7-sonnet", Object: "model"}, + }, + } + service, err := aliases.NewService(store, catalog) + require.NoError(t, err) + require.NoError(t, service.Refresh(context.Background())) + + mock := &mockProvider{ + supportedModels: []string{"claude-3-7-sonnet"}, + providerTypes: map[string]string{ + "anthropic/claude-3-7-sonnet": "anthropic", + }, + fileContentResponse: &core.FileContentResponse{ + ID: "file_source", + Filename: "batch.jsonl", + Data: []byte("{\"custom_id\":\"chat-1\",\"method\":\"POST\",\"url\":\"/v1/chat/completions\",\"body\":{\"model\":\"smart\",\"provider\":\"openai\",\"messages\":[{\"role\":\"user\",\"content\":\"Hi\"}]}}\n"), + }, + } + aliasBatchPreparer := aliases.NewBatchPreparer(mock, service) + + e := echo.New() + handler := NewHandler(mock, nil, nil, nil) + handler.modelResolver = service + handler.batchRequestPreparer = aliasBatchPreparer + + req := httptest.NewRequest(http.MethodPost, "/v1/batches", strings.NewReader(`{ + "input_file_id":"file_source", + "endpoint":"/v1/chat/completions", + "completion_window":"24h", + "metadata":{"provider":"openai"} + }`)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + + err = handler.Batches(c) + require.NoError(t, err) + require.Equal(t, http.StatusBadRequest, rec.Code) + require.Contains(t, rec.Body.String(), "native batch supports a single provider per batch") + require.Nil(t, mock.capturedBatchReq) + require.Empty(t, mock.capturedFileCreateReqs) +} + func TestBatches_RollsBackPreparedInputAndUpstreamBatchWhenStoreCreateFails(t *testing.T) { inner := &mockProvider{ batchCreateResponse: &core.BatchResponse{ diff --git a/tests/e2e/release-e2e-scenarios.md b/tests/e2e/release-e2e-scenarios.md index f5730d24..9215dce9 100644 --- a/tests/e2e/release-e2e-scenarios.md +++ b/tests/e2e/release-e2e-scenarios.md @@ -1,6 +1,6 @@ # Release E2E Curl Matrix -This file contains 62 end-to-end curl scenarios for release validation. +This file contains 79 end-to-end curl scenarios for release validation. These scenarios are prepared for execution across these local gateways: - `http://localhost:18080` - SQLite-backed main test gateway @@ -26,6 +26,38 @@ export BATCH_FILE=/tmp/qa-openai-batch.jsonl export UPLOAD_FILE=/tmp/qa-upload.txt ``` +## Auth-enabled runtime environment + +These scenarios target the auth-enabled live gateway on `http://localhost:8080` +and cover the newer workflows, managed API keys, and cache analytics features. + +```bash +set -a +source .env +set +a + +export AUTH_BASE_URL=http://localhost:8080 +export ADMIN_AUTH_HEADER="Authorization: Bearer $GOMODEL_MASTER_KEY" + +export QA_SUFFIX="${QA_SUFFIX:-$(date +%s)}" +export QA_AUTH_KEY_NAME="qa-release-auth-key-$QA_SUFFIX" +export QA_WORKFLOW_NAME="qa-release-workflow-$QA_SUFFIX" +export QA_USER_PATH="/team/release/e2e/$QA_SUFFIX" +export QA_CACHE_USER_PATH="/team/cache/e2e/$QA_SUFFIX" + +export QA_AUTH_KEY_JSON="/tmp/qa-release-auth-key-$QA_SUFFIX.json" +export QA_AUTH_KEY_VALUE_FILE="/tmp/qa-release-auth-key-$QA_SUFFIX.token" +export QA_WORKFLOW_JSON="/tmp/qa-release-workflow-$QA_SUFFIX.json" +export QA_WORKFLOW_ID_FILE="/tmp/qa-release-workflow-$QA_SUFFIX.id" + +export QA_AUTH_REQ1="qa-auth-cacheoff-$QA_SUFFIX-1" +export QA_AUTH_REQ2="qa-auth-cacheoff-$QA_SUFFIX-2" +export QA_CACHE_REQ1="qa-cache-exact-$QA_SUFFIX-1" +export QA_CACHE_REQ2="qa-cache-exact-$QA_SUFFIX-2" +export QA_DEACTIVATED_REQ="qa-auth-deactivated-$QA_SUFFIX" +export QA_CACHE_REPLY="QA_CACHE_EXACT_OK_$QA_SUFFIX" +``` + ## 1. Infra, discovery, observability ### S01 Health endpoint @@ -636,3 +668,200 @@ sleep 6 curl -sS "$BASE_URL/admin/api/v1/audit/log?request_id=$REQUEST_ID&limit=5" \ | jq '{total,entries:(.entries|map({request_id,path,model,provider,status_code,error_type}))}' ``` + +## 12. Authenticated runtime features + +### S63 Auth-enabled dashboard runtime config +Reads the allowlisted runtime flags for the live auth-enabled gateway. + +```bash +curl -sS "$AUTH_BASE_URL/admin/api/v1/dashboard/config" \ + -H "$ADMIN_AUTH_HEADER" \ + | jq '.' +``` + +### S64 Create managed API key +Creates one managed API key scoped to a release-specific user path and stores the one-time secret under `/tmp`. + +```bash +AUTH_KEY_JSON=$(curl -sS -X POST "$AUTH_BASE_URL/admin/api/v1/auth-keys" \ + -H "$ADMIN_AUTH_HEADER" \ + -H 'Content-Type: application/json' \ + -d "{\"name\":\"$QA_AUTH_KEY_NAME\",\"description\":\"Release e2e managed key\",\"user_path\":\"$QA_USER_PATH\"}") +printf '%s\n' "$AUTH_KEY_JSON" > "$QA_AUTH_KEY_JSON" +printf '%s\n' "$AUTH_KEY_JSON" | jq -r '.value' > "$QA_AUTH_KEY_VALUE_FILE" +printf '%s\n' "$AUTH_KEY_JSON" \ + | jq '{id,name,user_path,active,redacted_value,value}' +``` + +### S65 Verify managed API key list +Checks that the newly issued managed API key is visible and active. + +```bash +curl -sS "$AUTH_BASE_URL/admin/api/v1/auth-keys" \ + -H "$ADMIN_AUTH_HEADER" \ + | jq ".[] | select(.name==\"$QA_AUTH_KEY_NAME\") | {id,name,user_path,active,expires_at,redacted_value}" +``` + +### S66 Create user-path-scoped workflow with cache disabled +Creates a scoped workflow for `openai/gpt-4.1-nano` that disables cache for the managed-key user path. + +```bash +WORKFLOW_JSON=$(curl -sS -X POST "$AUTH_BASE_URL/admin/api/v1/execution-plans" \ + -H "$ADMIN_AUTH_HEADER" \ + -H 'Content-Type: application/json' \ + -d "{\"scope_provider\":\"openai\",\"scope_model\":\"gpt-4.1-nano\",\"scope_user_path\":\"$QA_USER_PATH\",\"name\":\"$QA_WORKFLOW_NAME\",\"description\":\"Disable cache for managed-key release e2e scope\",\"plan_payload\":{\"schema_version\":1,\"features\":{\"cache\":false,\"audit\":true,\"usage\":true,\"guardrails\":false,\"fallback\":false},\"guardrails\":[]}}") +printf '%s\n' "$WORKFLOW_JSON" > "$QA_WORKFLOW_JSON" +printf '%s\n' "$WORKFLOW_JSON" | jq -r '.id' > "$QA_WORKFLOW_ID_FILE" +printf '%s\n' "$WORKFLOW_JSON" \ + | jq '{id,name,scope,plan_payload}' +``` + +### S67 Verify scoped workflow detail +Reads the created workflow back and confirms the normalized scope and effective feature projection. + +```bash +WORKFLOW_ID=$(cat "$QA_WORKFLOW_ID_FILE") +curl -sS "$AUTH_BASE_URL/admin/api/v1/execution-plans/$WORKFLOW_ID" \ + -H "$ADMIN_AUTH_HEADER" \ + | jq '{id,name,scope,plan_payload,effective_features}' +``` + +### S68 Managed-key request through scoped workflow +Sends a request with the managed API key while also sending a conflicting `X-GoModel-User-Path` header. + +```bash +API_KEY=$(cat "$QA_AUTH_KEY_VALUE_FILE") +curl -sS -D - "$AUTH_BASE_URL/v1/chat/completions" \ + -H "Authorization: Bearer $API_KEY" \ + -H 'Content-Type: application/json' \ + -H "X-Request-ID: $QA_AUTH_REQ1" \ + -H 'X-GoModel-User-Path: /team/should-be-overridden' \ + -d '{"model":"openai/gpt-4.1-nano","messages":[{"role":"user","content":"Reply with exactly QA_AUTH_CACHE_OFF_OK"}],"max_tokens":16}' \ + | sed -n '1,20p' +``` + +### S69 Repeated managed-key request should still bypass cache +Repeats the same request and expects another live provider response rather than `X-Cache: HIT`. + +```bash +API_KEY=$(cat "$QA_AUTH_KEY_VALUE_FILE") +curl -sS -D - "$AUTH_BASE_URL/v1/chat/completions" \ + -H "Authorization: Bearer $API_KEY" \ + -H 'Content-Type: application/json' \ + -H "X-Request-ID: $QA_AUTH_REQ2" \ + -H 'X-GoModel-User-Path: /team/should-be-overridden' \ + -d '{"model":"openai/gpt-4.1-nano","messages":[{"role":"user","content":"Reply with exactly QA_AUTH_CACHE_OFF_OK"}],"max_tokens":16}' \ + | sed -n '1,20p' +``` + +### S70 Audit evidence for managed-key scoped workflow +Confirms that auth method, managed auth key ID, normalized user path, workflow ID, and no cache hit are all recorded together. + +```bash +curl -sS "$AUTH_BASE_URL/admin/api/v1/audit/log?request_id=$QA_AUTH_REQ2&limit=5" \ + -H "$ADMIN_AUTH_HEADER" \ + | jq '{total,entries:(.entries|map({request_id,status_code,auth_method,auth_key_id,user_path,execution_plan_version_id,cache_type,answer:.data.response_body.choices[0].message.content}))}' +``` + +### S71 Global cache warm request with explicit user path +Warms the global cache-enabled workflow using the master key and a cache-specific user path. + +```bash +curl -sS -D - "$AUTH_BASE_URL/v1/chat/completions" \ + -H "$ADMIN_AUTH_HEADER" \ + -H 'Content-Type: application/json' \ + -H "X-Request-ID: $QA_CACHE_REQ1" \ + -H "X-GoModel-User-Path: $QA_CACHE_USER_PATH" \ + -d "{\"model\":\"openai/gpt-4.1-nano\",\"messages\":[{\"role\":\"user\",\"content\":\"Reply with exactly $QA_CACHE_REPLY\"}],\"max_tokens\":16}" \ + | sed -n '1,20p' +``` + +### S72 Repeated global cache request should hit exact cache +Repeats the same request and expects `X-Cache: HIT (exact)`. + +```bash +curl -sS -D - "$AUTH_BASE_URL/v1/chat/completions" \ + -H "$ADMIN_AUTH_HEADER" \ + -H 'Content-Type: application/json' \ + -H "X-Request-ID: $QA_CACHE_REQ2" \ + -H "X-GoModel-User-Path: $QA_CACHE_USER_PATH" \ + -d "{\"model\":\"openai/gpt-4.1-nano\",\"messages\":[{\"role\":\"user\",\"content\":\"Reply with exactly $QA_CACHE_REPLY\"}],\"max_tokens\":16}" \ + | sed -n '1,20p' +``` + +### S73 Cache overview filtered by user path +Checks cache analytics after the exact-cache hit using the same tracked user path. + +```bash +sleep 6 +curl -sS "$AUTH_BASE_URL/admin/api/v1/cache/overview?days=1&user_path=$QA_CACHE_USER_PATH" \ + -H "$ADMIN_AUTH_HEADER" \ + | jq '.' +``` + +### S74 Cached usage log filtered by user path +Reads cached-only usage entries for the same exact-hit request path. + +```bash +curl -sS "$AUTH_BASE_URL/admin/api/v1/usage/log?days=1&user_path=$QA_CACHE_USER_PATH&cache_mode=cached&limit=5" \ + -H "$ADMIN_AUTH_HEADER" \ + | jq '{total,entries:(.entries|map({request_id,cache_type,model,provider,endpoint,user_path,total_tokens}))}' +``` + +### S75 Invalid managed API key user path (negative) +Verifies user-path validation for managed API key creation. + +```bash +curl -sS -i -X POST "$AUTH_BASE_URL/admin/api/v1/auth-keys" \ + -H "$ADMIN_AUTH_HEADER" \ + -H 'Content-Type: application/json' \ + -d '{"name":"qa-invalid-user-path","user_path":"/team/../alpha"}' \ + | sed -n '1,20p' +``` + +### S76 Invalid workflow scope user path (negative) +Verifies user-path validation for workflow creation. + +```bash +curl -sS -i -X POST "$AUTH_BASE_URL/admin/api/v1/execution-plans" \ + -H "$ADMIN_AUTH_HEADER" \ + -H 'Content-Type: application/json' \ + -d '{"scope_provider":"openai","scope_model":"gpt-4.1-nano","scope_user_path":"/team/../alpha","name":"qa-invalid-workflow-path","plan_payload":{"schema_version":1,"features":{"cache":true,"audit":true,"usage":true,"guardrails":false},"guardrails":[]}}' \ + | sed -n '1,24p' +``` + +## 13. Authenticated cleanup + +### S77 Deactivate managed API key +Deactivates the managed key created for the auth-enabled release run. + +```bash +AUTH_KEY_ID=$(curl -sS "$AUTH_BASE_URL/admin/api/v1/auth-keys" \ + -H "$ADMIN_AUTH_HEADER" \ + | jq -r ".[] | select(.name==\"$QA_AUTH_KEY_NAME\") | .id") +curl -sS -i -X POST "$AUTH_BASE_URL/admin/api/v1/auth-keys/$AUTH_KEY_ID/deactivate" \ + -H "$ADMIN_AUTH_HEADER" +``` + +### S78 Deactivated managed API key is rejected +Confirms that the same managed key can no longer authenticate requests. + +```bash +API_KEY=$(cat "$QA_AUTH_KEY_VALUE_FILE") +curl -sS -i "$AUTH_BASE_URL/v1/chat/completions" \ + -H "Authorization: Bearer $API_KEY" \ + -H 'Content-Type: application/json' \ + -H "X-Request-ID: $QA_DEACTIVATED_REQ" \ + -d '{"model":"openai/gpt-4.1-nano","messages":[{"role":"user","content":"Reply with exactly QA_AUTH_DEACTIVATED"}],"max_tokens":16}' \ + | sed -n '1,20p' +``` + +### S79 Deactivate scoped workflow +Deactivates the workflow created for the scoped managed-key release run. + +```bash +WORKFLOW_ID=$(cat "$QA_WORKFLOW_ID_FILE") +curl -sS -i -X POST "$AUTH_BASE_URL/admin/api/v1/execution-plans/$WORKFLOW_ID/deactivate" \ + -H "$ADMIN_AUTH_HEADER" +``` From 19223337f848f0b020ee4f912a42d1174cafd6e6 Mon Sep 17 00:00:00 2001 From: "Jakub A. W" Date: Wed, 1 Apr 2026 13:38:55 +0200 Subject: [PATCH 2/5] fix(aliases): preserve explicit provider selectors --- internal/aliases/batch_preparer_test.go | 8 +-- internal/aliases/service.go | 6 -- internal/aliases/service_test.go | 10 +-- internal/providers/openai/openai_test.go | 3 + internal/server/handlers_test.go | 4 +- tests/e2e/release-e2e-scenarios.md | 81 +++++++++++++++++++++++- 6 files changed, 94 insertions(+), 18 deletions(-) diff --git a/internal/aliases/batch_preparer_test.go b/internal/aliases/batch_preparer_test.go index 648e10f5..ed2dd1eb 100644 --- a/internal/aliases/batch_preparer_test.go +++ b/internal/aliases/batch_preparer_test.go @@ -94,7 +94,7 @@ func TestBatchPreparerRejectsAliasResolvedToDifferentProvider(t *testing.T) { } } -func TestBatchPreparerRejectsAliasResolvedToDifferentProviderWithExplicitProviderField(t *testing.T) { +func TestBatchPreparerRejectsUnsupportedExplicitProviderSelector(t *testing.T) { catalog := newTestCatalog() catalog.add("anthropic/claude-3-7-sonnet", "anthropic", core.Model{ID: "claude-3-7-sonnet", Object: "model"}) @@ -121,10 +121,10 @@ func TestBatchPreparerRejectsAliasResolvedToDifferentProviderWithExplicitProvide Endpoint: "/v1/chat/completions", }) if err == nil { - t.Fatal("PrepareBatchRequest() error = nil, want provider mismatch") + t.Fatal("PrepareBatchRequest() error = nil, want unsupported model error") } - if !strings.Contains(err.Error(), `native batch supports a single provider per batch`) { - t.Fatalf("PrepareBatchRequest() error = %v, want mixed-provider validation error", err) + if !strings.Contains(err.Error(), `unsupported model: openai/smart`) { + t.Fatalf("PrepareBatchRequest() error = %v, want unsupported model: openai/smart", err) } if len(inner.fileCreates) != 0 { t.Fatalf("len(fileCreates) = %d, want 0", len(inner.fileCreates)) diff --git a/internal/aliases/service.go b/internal/aliases/service.go index d537e88d..44367db9 100644 --- a/internal/aliases/service.go +++ b/internal/aliases/service.go @@ -128,12 +128,6 @@ func (s *Service) resolveRequested(requested core.RequestedModelSelector) (Resol } if requested.ExplicitProvider { - if !s.catalog.Supports(selector.QualifiedModel()) { - if resolution, ok := s.resolveAlias(requested.Model); ok { - resolution.Requested = selector - return resolution, true, nil - } - } return Resolution{Requested: selector, Resolved: selector}, false, nil } diff --git a/internal/aliases/service_test.go b/internal/aliases/service_test.go index e8055b28..89fbec94 100644 --- a/internal/aliases/service_test.go +++ b/internal/aliases/service_test.go @@ -235,7 +235,7 @@ func TestServiceResolveAliasWithExplicitProviderAndSlashModel(t *testing.T) { } } -func TestServiceResolveAliasWithExplicitProviderFallbackWhenConcreteSelectorMissing(t *testing.T) { +func TestServiceResolveAliasWithExplicitProviderPreservesRequestedSelector(t *testing.T) { catalog := newTestCatalog() catalog.add("anthropic/claude-3-7-sonnet", "anthropic", core.Model{ID: "claude-3-7-sonnet", Object: "model"}) @@ -256,11 +256,11 @@ func TestServiceResolveAliasWithExplicitProviderFallbackWhenConcreteSelectorMiss if err != nil { t.Fatalf("ResolveModel() error = %v", err) } - if !changed { - t.Fatal("ResolveModel() changed = false, want true") + if changed { + t.Fatal("ResolveModel() changed = true, want false") } - if got := selector.QualifiedModel(); got != "anthropic/claude-3-7-sonnet" { - t.Fatalf("resolved selector = %q, want anthropic/claude-3-7-sonnet", got) + if got := selector.QualifiedModel(); got != "openai/smart" { + t.Fatalf("resolved selector = %q, want openai/smart", got) } } diff --git a/internal/providers/openai/openai_test.go b/internal/providers/openai/openai_test.go index 4c48480e..fa0917c6 100644 --- a/internal/providers/openai/openai_test.go +++ b/internal/providers/openai/openai_test.go @@ -1849,6 +1849,9 @@ func TestStreamChatCompletion_GPT5Model_AdaptsParameters(t *testing.T) { if _, ok := raw["max_tokens"]; ok { t.Error("streaming gpt-5 request should not contain max_tokens") } + if _, ok := raw["temperature"]; ok { + t.Error("streaming gpt-5 request should not contain temperature") + } mct, ok := raw["max_completion_tokens"] if !ok { t.Fatal("streaming gpt-5 request should contain max_completion_tokens") diff --git a/internal/server/handlers_test.go b/internal/server/handlers_test.go index 1e45a83b..7497b96a 100644 --- a/internal/server/handlers_test.go +++ b/internal/server/handlers_test.go @@ -3435,7 +3435,7 @@ func TestBatches_InputFileRewritesAliasesAndPersistsBatchPreparation(t *testing. require.Equal(t, "file_rewritten", stored.RewrittenInputFileID) } -func TestBatches_InputFileRejectsExplicitProviderAliasMismatch(t *testing.T) { +func TestBatches_InputFileRejectsUnsupportedExplicitProviderSelector(t *testing.T) { store := newAliasesTestStore(aliases.Alias{Name: "smart", TargetModel: "claude-3-7-sonnet", TargetProvider: "anthropic", Enabled: true}) catalog := &aliasesTestCatalog{ supported: map[string]bool{ @@ -3483,7 +3483,7 @@ func TestBatches_InputFileRejectsExplicitProviderAliasMismatch(t *testing.T) { err = handler.Batches(c) require.NoError(t, err) require.Equal(t, http.StatusBadRequest, rec.Code) - require.Contains(t, rec.Body.String(), "native batch supports a single provider per batch") + require.Contains(t, rec.Body.String(), "unsupported model: openai/smart") require.Nil(t, mock.capturedBatchReq) require.Empty(t, mock.capturedFileCreateReqs) } diff --git a/tests/e2e/release-e2e-scenarios.md b/tests/e2e/release-e2e-scenarios.md index 9215dce9..65242e83 100644 --- a/tests/e2e/release-e2e-scenarios.md +++ b/tests/e2e/release-e2e-scenarios.md @@ -61,6 +61,7 @@ export QA_CACHE_REPLY="QA_CACHE_EXACT_OK_$QA_SUFFIX" ## 1. Infra, discovery, observability ### S01 Health endpoint + Checks basic liveness on the main SQLite-backed gateway. ```bash @@ -68,6 +69,7 @@ curl -sS "$BASE_URL/health" ``` ### S02 Metrics endpoint + Checks that Prometheus metrics are exposed. ```bash @@ -75,6 +77,7 @@ curl -sS "$BASE_URL/metrics" | sed -n '1,20p' ``` ### S03 Public models list + Checks `/v1/models` and prints a small sample. ```bash @@ -83,6 +86,7 @@ curl -sS "$BASE_URL/v1/models" \ ``` ### S04 Admin model inventory + Checks `/admin/api/v1/models`. ```bash @@ -90,6 +94,7 @@ curl -sS "$BASE_URL/admin/api/v1/models" | jq '.[0:5]' ``` ### S05 Admin model categories + Checks `/admin/api/v1/models/categories`. ```bash @@ -97,6 +102,7 @@ curl -sS "$BASE_URL/admin/api/v1/models/categories" | jq '.' ``` ### S06 Usage summary endpoint + Reads aggregate usage summary. ```bash @@ -104,6 +110,7 @@ curl -sS "$BASE_URL/admin/api/v1/usage/summary" | jq '.' ``` ### S07 Usage daily endpoint + Reads daily usage rollup. ```bash @@ -111,6 +118,7 @@ curl -sS "$BASE_URL/admin/api/v1/usage/daily?days=7" | jq '.' ``` ### S08 Usage by model endpoint + Reads per-model usage totals. ```bash @@ -118,6 +126,7 @@ curl -sS "$BASE_URL/admin/api/v1/usage/models?limit=10" | jq '.' ``` ### S09 Filtered usage log + Reads recent usage entries for a specific model. ```bash @@ -126,6 +135,7 @@ curl -sS "$BASE_URL/admin/api/v1/usage/log?model=gpt-4.1-nano-2025-04-14&limit=5 ``` ### S10 Audit log endpoint + Reads recent audit entries. ```bash @@ -134,6 +144,7 @@ curl -sS "$BASE_URL/admin/api/v1/audit/log?limit=5" \ ``` ### S11 Audit conversation endpoint + Reads a conversation thread anchored to the newest audit entry. ```bash @@ -143,6 +154,7 @@ curl -sS "$BASE_URL/admin/api/v1/audit/conversation?log_id=$AUDIT_ID&limit=5" \ ``` ### S12 Alias list endpoint + Reads current aliases. ```bash @@ -152,6 +164,7 @@ curl -sS "$BASE_URL/admin/api/v1/aliases" | jq '.' ## 2. Alias administration ### S13 Create OpenAI alias + Creates an alias pointing to the newest cheap OpenAI model. ```bash @@ -162,6 +175,7 @@ curl -sS -X PUT "$BASE_URL/admin/api/v1/aliases/qa-gpt-latest" \ ``` ### S14 Create Anthropic alias + Creates an alias pointing to `claude-sonnet-4-6`. ```bash @@ -172,6 +186,7 @@ curl -sS -X PUT "$BASE_URL/admin/api/v1/aliases/qa-sonnet-thinking" \ ``` ### S15 Verify aliases are exposed in `/v1/models` + Checks that aliases are discoverable through the public model list. ```bash @@ -182,6 +197,7 @@ curl -sS "$BASE_URL/v1/models" \ ## 3. Chat completions ### S16 OpenAI non-streaming chat + Basic OpenAI-compatible chat completion. ```bash @@ -192,6 +208,7 @@ curl -sS "$BASE_URL/v1/chat/completions" \ ``` ### S17 OpenAI streaming chat + Checks SSE chat streaming and final usage chunk. ```bash @@ -202,6 +219,7 @@ curl -sS --no-buffer "$BASE_URL/v1/chat/completions" \ ``` ### S18 Older OpenAI model + Regression probe against `gpt-3.5-turbo`. ```bash @@ -212,6 +230,7 @@ curl -sS "$BASE_URL/v1/chat/completions" \ ``` ### S19 Anthropic Sonnet 4.6 with reasoning + Checks extended-thinking compatible request flow through the chat endpoint. ```bash @@ -222,6 +241,7 @@ curl -sS "$BASE_URL/v1/chat/completions" \ ``` ### S20 Gemini chat + Checks translated chat on Gemini. ```bash @@ -232,6 +252,7 @@ curl -sS "$BASE_URL/v1/chat/completions" \ ``` ### S21 Groq chat + Checks translated chat on Groq. ```bash @@ -242,6 +263,7 @@ curl -sS "$BASE_URL/v1/chat/completions" \ ``` ### S22 xAI chat + Checks translated chat on xAI and reasoning-token accounting. ```bash @@ -252,6 +274,7 @@ curl -sS "$BASE_URL/v1/chat/completions" \ ``` ### S23 Multimodal chat with image URL + Checks multimodal chat completion with image input. ```bash @@ -262,6 +285,7 @@ curl -sS "$BASE_URL/v1/chat/completions" \ ``` ### S24 Chat through OpenAI alias + Checks alias resolution for OpenAI models. ```bash @@ -272,6 +296,7 @@ curl -sS "$BASE_URL/v1/chat/completions" \ ``` ### S25 Chat through Anthropic alias + Checks alias resolution for Anthropic models plus reasoning. ```bash @@ -282,6 +307,7 @@ curl -sS "$BASE_URL/v1/chat/completions" \ ``` ### S26 Latest GPT reasoning on chat (negative) + Reproduces the current gap for `reasoning` on `gpt-5-nano` via chat completions. ```bash @@ -293,6 +319,7 @@ curl -sS -i "$BASE_URL/v1/chat/completions" \ ## 4. Responses API ### S27 Non-streaming responses request + Checks basic `/v1/responses`. ```bash @@ -303,6 +330,7 @@ curl -sS "$BASE_URL/v1/responses" \ ``` ### S28 Streaming responses request + Checks SSE responses streaming. ```bash @@ -313,6 +341,7 @@ curl -sS --no-buffer "$BASE_URL/v1/responses" \ ``` ### S29 Latest GPT reasoning via responses + Checks the preferred latest-GPT reasoning path. ```bash @@ -323,6 +352,7 @@ curl -sS "$BASE_URL/v1/responses" \ ``` ### S30 Multimodal responses request + Checks multimodal input through the Responses API. ```bash @@ -333,6 +363,7 @@ curl -sS "$BASE_URL/v1/responses" \ ``` ### S31 Responses through OpenAI alias + Checks alias resolution on `/v1/responses`. ```bash @@ -345,6 +376,7 @@ curl -sS "$BASE_URL/v1/responses" \ ## 5. Embeddings ### S32 OpenAI embeddings, single input + Checks single-item embedding generation. ```bash @@ -355,6 +387,7 @@ curl -sS "$BASE_URL/v1/embeddings" \ ``` ### S33 OpenAI embeddings, batch input + Checks multi-item embedding generation. ```bash @@ -365,6 +398,7 @@ curl -sS "$BASE_URL/v1/embeddings" \ ``` ### S34 Gemini embeddings + Checks embeddings on Gemini. ```bash @@ -377,6 +411,7 @@ curl -sS "$BASE_URL/v1/embeddings" \ ## 6. Files ### S35 Upload batch input file to OpenAI + Uploads the shared batch fixture. ```bash @@ -387,6 +422,7 @@ curl -sS "$BASE_URL/v1/files?provider=openai" \ ``` ### S36 List OpenAI batch files + Lists uploaded batch files. ```bash @@ -395,6 +431,7 @@ curl -sS "$BASE_URL/v1/files?provider=openai&purpose=batch&limit=5" \ ``` ### S37 Get uploaded batch file metadata + Fetches metadata for the newest batch file. ```bash @@ -403,6 +440,7 @@ curl -sS "$BASE_URL/v1/files/$FILE_ID?provider=openai" | jq '.' ``` ### S38 Get uploaded batch file content + Fetches raw content for the newest batch file. ```bash @@ -411,6 +449,7 @@ curl -sS "$BASE_URL/v1/files/$FILE_ID/content?provider=openai" ``` ### S39 Upload assistants file to OpenAI + Uploads a small text file for create/delete lifecycle testing. ```bash @@ -421,6 +460,7 @@ curl -sS "$BASE_URL/v1/files?provider=openai" \ ``` ### S40 Delete assistants file + Deletes the newest assistants-purpose file. ```bash @@ -431,6 +471,7 @@ curl -sS -X DELETE "$BASE_URL/v1/files/$FILE_ID?provider=openai" | jq '.' ## 7. Native batches ### S41 File batch create without `metadata.provider` (negative) + Reproduces the current compatibility gap for file-based native batches. ```bash @@ -442,6 +483,7 @@ curl -sS "$BASE_URL/v1/batches" \ ``` ### S42 File batch create with `metadata.provider` + Creates an OpenAI native batch successfully. ```bash @@ -453,6 +495,7 @@ curl -sS "$BASE_URL/v1/batches" \ ``` ### S43 List batches + Lists stored batches. ```bash @@ -461,6 +504,7 @@ curl -sS "$BASE_URL/v1/batches?limit=5" \ ``` ### S44 Get stored OpenAI batch + Reads the newest OpenAI batch. ```bash @@ -469,6 +513,7 @@ curl -sS "$BASE_URL/v1/batches/$BATCH_ID" | jq '.' ``` ### S45 Get OpenAI batch results before ready (negative) + Checks current pending-results behavior. ```bash @@ -477,6 +522,7 @@ curl -sS -i "$BASE_URL/v1/batches/$BATCH_ID/results" ``` ### S46 Cancel OpenAI batch + Cancels the newest OpenAI batch. ```bash @@ -485,6 +531,7 @@ curl -sS -X POST "$BASE_URL/v1/batches/$BATCH_ID/cancel" | jq '.' ``` ### S47 Create inline Anthropic batch + Checks provider-native inline batch support. ```bash @@ -495,6 +542,7 @@ curl -sS "$BASE_URL/v1/batches" \ ``` ### S48 Mixed-provider alias batch rejection (negative) + Checks that a batch provider mismatch is rejected before upstream submission. ```bash @@ -510,6 +558,7 @@ curl -sS -i "$BASE_URL/v1/batches" \ ## 8. Provider passthrough ### S49 OpenAI passthrough with `/v1` + Checks raw passthrough to OpenAI. ```bash @@ -520,6 +569,7 @@ curl -sS -i "$BASE_URL/p/openai/v1/chat/completions" \ ``` ### S50 OpenAI passthrough without `/v1` + Checks endpoint normalization for passthrough. ```bash @@ -531,6 +581,7 @@ curl -sS "$BASE_URL/p/openai/chat/completions" \ ``` ### S51 Anthropic passthrough + Checks raw passthrough to Anthropic messages API. ```bash @@ -541,6 +592,7 @@ curl -sS -i "$BASE_URL/p/anthropic/v1/messages" \ ``` ### S52 Passthrough normalized error + Checks that passthrough upstream errors are normalized to gateway error shape. ```bash @@ -550,6 +602,7 @@ curl -sS -i "$BASE_URL/p/openai/v1/chat/completions" \ ``` ### S53 Passthrough streaming SSE + Checks raw streaming passthrough behavior. ```bash @@ -563,6 +616,7 @@ curl -sS --no-buffer "$BASE_URL/p/openai/v1/chat/completions" \ ## 9. Storage backends and guardrails ### S54 PostgreSQL smoke + Checks health, one model request, then admin usage/audit after the flush interval. ```bash @@ -578,6 +632,7 @@ curl -sS "$PG_BASE_URL/admin/api/v1/audit/log?limit=3" \ ``` ### S55 MongoDB smoke + Checks health, one model request, then admin audit/usage on MongoDB storage. ```bash @@ -594,6 +649,7 @@ curl -sS "$MONGO_BASE_URL/admin/api/v1/audit/log?limit=3" \ ``` ### S56 Guardrail chat override + Checks that a system-prompt guardrail overrides normal chat output. ```bash @@ -604,6 +660,7 @@ curl -sS "$GR_BASE_URL/v1/chat/completions" \ ``` ### S57 Guardrail responses override + Checks the same guardrail path on `/v1/responses`. ```bash @@ -614,6 +671,7 @@ curl -sS "$GR_BASE_URL/v1/responses" \ ``` ### S58 Guardrail audit and usage smoke + Reads admin evidence after the guardrail requests flush. ```bash @@ -626,6 +684,7 @@ curl -sS "$GR_BASE_URL/admin/api/v1/usage/summary" | jq '.' ## 10. Alias cleanup ### S59 Delete OpenAI alias + Removes `qa-gpt-latest`. ```bash @@ -633,6 +692,7 @@ curl -sS -X DELETE -i "$BASE_URL/admin/api/v1/aliases/qa-gpt-latest" ``` ### S60 Delete Anthropic alias + Removes `qa-sonnet-thinking`. ```bash @@ -642,6 +702,7 @@ curl -sS -X DELETE -i "$BASE_URL/admin/api/v1/aliases/qa-sonnet-thinking" ## 11. Audit failure coverage ### S61 Unsupported translated model is still written to audit log + Checks that a rejected translated request is still visible in audit logs with the requested model and error type. ```bash @@ -656,6 +717,7 @@ curl -sS "$BASE_URL/admin/api/v1/audit/log?request_id=$REQUEST_ID&limit=5" \ ``` ### S62 Unsupported passthrough provider is still written to audit log + Checks that a rejected passthrough request is still visible in audit logs with the provider parsed from the path. ```bash @@ -672,6 +734,7 @@ curl -sS "$BASE_URL/admin/api/v1/audit/log?request_id=$REQUEST_ID&limit=5" \ ## 12. Authenticated runtime features ### S63 Auth-enabled dashboard runtime config + Reads the allowlisted runtime flags for the live auth-enabled gateway. ```bash @@ -681,6 +744,7 @@ curl -sS "$AUTH_BASE_URL/admin/api/v1/dashboard/config" \ ``` ### S64 Create managed API key + Creates one managed API key scoped to a release-specific user path and stores the one-time secret under `/tmp`. ```bash @@ -691,10 +755,11 @@ AUTH_KEY_JSON=$(curl -sS -X POST "$AUTH_BASE_URL/admin/api/v1/auth-keys" \ printf '%s\n' "$AUTH_KEY_JSON" > "$QA_AUTH_KEY_JSON" printf '%s\n' "$AUTH_KEY_JSON" | jq -r '.value' > "$QA_AUTH_KEY_VALUE_FILE" printf '%s\n' "$AUTH_KEY_JSON" \ - | jq '{id,name,user_path,active,redacted_value,value}' + | jq '{id,name,user_path,active,redacted_value}' ``` ### S65 Verify managed API key list + Checks that the newly issued managed API key is visible and active. ```bash @@ -704,6 +769,7 @@ curl -sS "$AUTH_BASE_URL/admin/api/v1/auth-keys" \ ``` ### S66 Create user-path-scoped workflow with cache disabled + Creates a scoped workflow for `openai/gpt-4.1-nano` that disables cache for the managed-key user path. ```bash @@ -718,6 +784,7 @@ printf '%s\n' "$WORKFLOW_JSON" \ ``` ### S67 Verify scoped workflow detail + Reads the created workflow back and confirms the normalized scope and effective feature projection. ```bash @@ -728,6 +795,7 @@ curl -sS "$AUTH_BASE_URL/admin/api/v1/execution-plans/$WORKFLOW_ID" \ ``` ### S68 Managed-key request through scoped workflow + Sends a request with the managed API key while also sending a conflicting `X-GoModel-User-Path` header. ```bash @@ -742,6 +810,7 @@ curl -sS -D - "$AUTH_BASE_URL/v1/chat/completions" \ ``` ### S69 Repeated managed-key request should still bypass cache + Repeats the same request and expects another live provider response rather than `X-Cache: HIT`. ```bash @@ -756,6 +825,7 @@ curl -sS -D - "$AUTH_BASE_URL/v1/chat/completions" \ ``` ### S70 Audit evidence for managed-key scoped workflow + Confirms that auth method, managed auth key ID, normalized user path, workflow ID, and no cache hit are all recorded together. ```bash @@ -765,6 +835,7 @@ curl -sS "$AUTH_BASE_URL/admin/api/v1/audit/log?request_id=$QA_AUTH_REQ2&limit=5 ``` ### S71 Global cache warm request with explicit user path + Warms the global cache-enabled workflow using the master key and a cache-specific user path. ```bash @@ -778,6 +849,7 @@ curl -sS -D - "$AUTH_BASE_URL/v1/chat/completions" \ ``` ### S72 Repeated global cache request should hit exact cache + Repeats the same request and expects `X-Cache: HIT (exact)`. ```bash @@ -791,6 +863,7 @@ curl -sS -D - "$AUTH_BASE_URL/v1/chat/completions" \ ``` ### S73 Cache overview filtered by user path + Checks cache analytics after the exact-cache hit using the same tracked user path. ```bash @@ -801,6 +874,7 @@ curl -sS "$AUTH_BASE_URL/admin/api/v1/cache/overview?days=1&user_path=$QA_CACHE_ ``` ### S74 Cached usage log filtered by user path + Reads cached-only usage entries for the same exact-hit request path. ```bash @@ -810,6 +884,7 @@ curl -sS "$AUTH_BASE_URL/admin/api/v1/usage/log?days=1&user_path=$QA_CACHE_USER_ ``` ### S75 Invalid managed API key user path (negative) + Verifies user-path validation for managed API key creation. ```bash @@ -821,6 +896,7 @@ curl -sS -i -X POST "$AUTH_BASE_URL/admin/api/v1/auth-keys" \ ``` ### S76 Invalid workflow scope user path (negative) + Verifies user-path validation for workflow creation. ```bash @@ -834,6 +910,7 @@ curl -sS -i -X POST "$AUTH_BASE_URL/admin/api/v1/execution-plans" \ ## 13. Authenticated cleanup ### S77 Deactivate managed API key + Deactivates the managed key created for the auth-enabled release run. ```bash @@ -845,6 +922,7 @@ curl -sS -i -X POST "$AUTH_BASE_URL/admin/api/v1/auth-keys/$AUTH_KEY_ID/deactiva ``` ### S78 Deactivated managed API key is rejected + Confirms that the same managed key can no longer authenticate requests. ```bash @@ -858,6 +936,7 @@ curl -sS -i "$AUTH_BASE_URL/v1/chat/completions" \ ``` ### S79 Deactivate scoped workflow + Deactivates the workflow created for the scoped managed-key release run. ```bash From 96c7a89fb954e426e1c16f15926d19415e4efd8f Mon Sep 17 00:00:00 2001 From: "Jakub A. W" Date: Wed, 1 Apr 2026 13:57:56 +0200 Subject: [PATCH 3/5] docs(e2e): secure managed key artifacts --- tests/e2e/release-e2e-scenarios.md | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/tests/e2e/release-e2e-scenarios.md b/tests/e2e/release-e2e-scenarios.md index 65242e83..50841a67 100644 --- a/tests/e2e/release-e2e-scenarios.md +++ b/tests/e2e/release-e2e-scenarios.md @@ -56,6 +56,13 @@ export QA_CACHE_REQ1="qa-cache-exact-$QA_SUFFIX-1" export QA_CACHE_REQ2="qa-cache-exact-$QA_SUFFIX-2" export QA_DEACTIVATED_REQ="qa-auth-deactivated-$QA_SUFFIX" export QA_CACHE_REPLY="QA_CACHE_EXACT_OK_$QA_SUFFIX" + +cleanup_release_auth_artifacts() { + rm -f "$QA_AUTH_KEY_JSON" "$QA_AUTH_KEY_VALUE_FILE" +} + +cleanup_release_auth_artifacts +trap 'cleanup_release_auth_artifacts' EXIT ``` ## 1. Infra, discovery, observability @@ -752,8 +759,12 @@ AUTH_KEY_JSON=$(curl -sS -X POST "$AUTH_BASE_URL/admin/api/v1/auth-keys" \ -H "$ADMIN_AUTH_HEADER" \ -H 'Content-Type: application/json' \ -d "{\"name\":\"$QA_AUTH_KEY_NAME\",\"description\":\"Release e2e managed key\",\"user_path\":\"$QA_USER_PATH\"}") -printf '%s\n' "$AUTH_KEY_JSON" > "$QA_AUTH_KEY_JSON" -printf '%s\n' "$AUTH_KEY_JSON" | jq -r '.value' > "$QA_AUTH_KEY_VALUE_FILE" +( + umask 077 + printf '%s\n' "$AUTH_KEY_JSON" > "$QA_AUTH_KEY_JSON" + printf '%s\n' "$AUTH_KEY_JSON" | jq -r '.value' > "$QA_AUTH_KEY_VALUE_FILE" +) +chmod 600 "$QA_AUTH_KEY_JSON" "$QA_AUTH_KEY_VALUE_FILE" printf '%s\n' "$AUTH_KEY_JSON" \ | jq '{id,name,user_path,active,redacted_value}' ``` @@ -943,4 +954,5 @@ Deactivates the workflow created for the scoped managed-key release run. WORKFLOW_ID=$(cat "$QA_WORKFLOW_ID_FILE") curl -sS -i -X POST "$AUTH_BASE_URL/admin/api/v1/execution-plans/$WORKFLOW_ID/deactivate" \ -H "$ADMIN_AUTH_HEADER" +rm -f "$QA_AUTH_KEY_JSON" "$QA_AUTH_KEY_VALUE_FILE" ``` From 0ff9cb36d6b0a1c3cdd6f867e082fe8ad354e5d4 Mon Sep 17 00:00:00 2001 From: "Jakub A. W" Date: Wed, 1 Apr 2026 14:19:39 +0200 Subject: [PATCH 4/5] docs(e2e): harden auth runtime setup --- tests/e2e/release-e2e-scenarios.md | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/tests/e2e/release-e2e-scenarios.md b/tests/e2e/release-e2e-scenarios.md index 50841a67..33c214ee 100644 --- a/tests/e2e/release-e2e-scenarios.md +++ b/tests/e2e/release-e2e-scenarios.md @@ -32,6 +32,12 @@ These scenarios target the auth-enabled live gateway on `http://localhost:8080` and cover the newer workflows, managed API keys, and cache analytics features. ```bash +set -euo pipefail +if [ ! -r .env ]; then + echo "error: .env is missing or unreadable" >&2 + exit 1 +fi + set -a source .env set +a @@ -58,7 +64,7 @@ export QA_DEACTIVATED_REQ="qa-auth-deactivated-$QA_SUFFIX" export QA_CACHE_REPLY="QA_CACHE_EXACT_OK_$QA_SUFFIX" cleanup_release_auth_artifacts() { - rm -f "$QA_AUTH_KEY_JSON" "$QA_AUTH_KEY_VALUE_FILE" + rm -f "$QA_AUTH_KEY_JSON" "$QA_AUTH_KEY_VALUE_FILE" "$QA_WORKFLOW_JSON" "$QA_WORKFLOW_ID_FILE" } cleanup_release_auth_artifacts @@ -759,10 +765,17 @@ AUTH_KEY_JSON=$(curl -sS -X POST "$AUTH_BASE_URL/admin/api/v1/auth-keys" \ -H "$ADMIN_AUTH_HEADER" \ -H 'Content-Type: application/json' \ -d "{\"name\":\"$QA_AUTH_KEY_NAME\",\"description\":\"Release e2e managed key\",\"user_path\":\"$QA_USER_PATH\"}") +AUTH_KEY_VALUE=$(printf '%s\n' "$AUTH_KEY_JSON" \ + | jq -er '.value | select(type == "string" and length > 0)') \ + || { + echo "error: managed API key creation failed or did not return a usable one-time key value" >&2 + printf '%s\n' "$AUTH_KEY_JSON" | jq '.' >&2 2>/dev/null || printf '%s\n' "$AUTH_KEY_JSON" >&2 + exit 1 + } ( umask 077 printf '%s\n' "$AUTH_KEY_JSON" > "$QA_AUTH_KEY_JSON" - printf '%s\n' "$AUTH_KEY_JSON" | jq -r '.value' > "$QA_AUTH_KEY_VALUE_FILE" + printf '%s\n' "$AUTH_KEY_VALUE" > "$QA_AUTH_KEY_VALUE_FILE" ) chmod 600 "$QA_AUTH_KEY_JSON" "$QA_AUTH_KEY_VALUE_FILE" printf '%s\n' "$AUTH_KEY_JSON" \ @@ -954,5 +967,5 @@ Deactivates the workflow created for the scoped managed-key release run. WORKFLOW_ID=$(cat "$QA_WORKFLOW_ID_FILE") curl -sS -i -X POST "$AUTH_BASE_URL/admin/api/v1/execution-plans/$WORKFLOW_ID/deactivate" \ -H "$ADMIN_AUTH_HEADER" -rm -f "$QA_AUTH_KEY_JSON" "$QA_AUTH_KEY_VALUE_FILE" +rm -f "$QA_AUTH_KEY_JSON" "$QA_AUTH_KEY_VALUE_FILE" "$QA_WORKFLOW_JSON" "$QA_WORKFLOW_ID_FILE" ``` From 888e3fdd382e68f0d1328c48fae458c7a9e12e86 Mon Sep 17 00:00:00 2001 From: "Jakub A. W" Date: Wed, 1 Apr 2026 14:41:12 +0200 Subject: [PATCH 5/5] refactor(dashboard): unify copy button behavior --- .../admin/dashboard/static/css/dashboard.css | 4 +- .../dashboard/static/js/modules/audit-list.js | 46 +++--- .../static/js/modules/audit-list.test.js | 78 +++++++++++ .../dashboard/static/js/modules/auth-keys.js | 110 ++++----------- .../static/js/modules/auth-keys.test.js | 22 +-- .../dashboard/static/js/modules/clipboard.js | 131 ++++++++++++++++++ .../js/modules/dashboard-layout.test.js | 6 +- .../admin/dashboard/templates/audit-pane.html | 34 +++-- internal/admin/dashboard/templates/index.html | 12 +- .../admin/dashboard/templates/layout.html | 1 + 10 files changed, 308 insertions(+), 136 deletions(-) create mode 100644 internal/admin/dashboard/static/js/modules/clipboard.js diff --git a/internal/admin/dashboard/static/css/dashboard.css b/internal/admin/dashboard/static/css/dashboard.css index 688cc9d6..0636a569 100644 --- a/internal/admin/dashboard/static/css/dashboard.css +++ b/internal/admin/dashboard/static/css/dashboard.css @@ -3137,14 +3137,14 @@ body.conversation-drawer-open { color: var(--danger); } -.auth-key-copy-btn { +.copy-feedback-btn { display: inline-flex; align-items: center; gap: 6px; transition: background-color 0.15s, border-color 0.15s, color 0.15s; } -.auth-key-copy-btn-copied { +.copy-feedback-btn-copied { background: color-mix(in srgb, var(--success) 12%, var(--bg)); border-color: color-mix(in srgb, var(--success) 40%, var(--border)); color: var(--success); diff --git a/internal/admin/dashboard/static/js/modules/audit-list.js b/internal/admin/dashboard/static/js/modules/audit-list.js index e32ce9db..2ce2a475 100644 --- a/internal/admin/dashboard/static/js/modules/audit-list.js +++ b/internal/admin/dashboard/static/js/modules/audit-list.js @@ -1,5 +1,12 @@ (function(global) { function dashboardAuditListModule() { + const clipboardModuleFactory = typeof global.dashboardClipboardModule === 'function' + ? global.dashboardClipboardModule + : null; + const clipboard = clipboardModuleFactory + ? clipboardModuleFactory() + : null; + return { _auditQueryStr() { if (this.customStartDate && this.customEndDate) { @@ -182,26 +189,27 @@ }; }, - async copyAuditJSON(v, event) { - if (v == null || v === undefined || v === '') return; - const button = event && event.currentTarget instanceof HTMLElement ? event.currentTarget : null; - if (button && !button.dataset.copyLabel) { - button.dataset.copyLabel = String(button.textContent || 'Copy').trim(); - } - try { - const payload = this.formatJSON(v); - await navigator.clipboard.writeText(payload); - } catch (e) { - console.error('Failed to copy audit payload:', e); - if (button) { - button.textContent = 'Copy failed'; - button.disabled = true; - window.setTimeout(() => { - button.textContent = button.dataset.copyLabel || 'Copy'; - button.disabled = false; - }, 2000); + auditPaneState(pane) { + const formatJSON = this.formatJSON.bind(this); + + return { + pane, + copyState: clipboard + ? clipboard.createClipboardButtonState({ + logPrefix: 'Failed to copy audit payload:' + }) + : { + copied: false, + error: false, + copy() { + return Promise.resolve(); + } + }, + + copyBody() { + return this.copyState.copy(this.pane.copyBody, formatJSON); } - } + }; } }; } diff --git a/internal/admin/dashboard/static/js/modules/audit-list.test.js b/internal/admin/dashboard/static/js/modules/audit-list.test.js index f36bce50..ca0bd2b8 100644 --- a/internal/admin/dashboard/static/js/modules/audit-list.test.js +++ b/internal/admin/dashboard/static/js/modules/audit-list.test.js @@ -5,6 +5,7 @@ const path = require('node:path'); const vm = require('node:vm'); function loadAuditListModuleFactory(overrides = {}) { + const clipboardSource = fs.readFileSync(path.join(__dirname, 'clipboard.js'), 'utf8'); const source = fs.readFileSync(path.join(__dirname, 'audit-list.js'), 'utf8'); const window = { ...(overrides.window || {}) @@ -15,6 +16,7 @@ function loadAuditListModuleFactory(overrides = {}) { window }; vm.createContext(context); + vm.runInContext(clipboardSource, context); vm.runInContext(source, context); return context.window.dashboardAuditListModule; } @@ -122,3 +124,79 @@ test('fetchAuditLog preserves a successful payload when workflow prefetch fails' assert.equal(loggedErrors.length, 1); assert.match(String(loggedErrors[0][0]), /Failed to prefetch audit workflows:/); }); + +test('auditPaneState copies the formatted body and resets success feedback', async () => { + let resetCallback = null; + const writes = []; + const module = createAuditListModule({ + setTimeout(callback) { + resetCallback = callback; + return 1; + }, + clearTimeout() {}, + window: { + navigator: { + clipboard: { + writeText(value) { + writes.push(value); + return Promise.resolve(); + } + } + } + } + }); + + const paneState = module.auditPaneState({ + copyBody: { model: 'gpt-5', stream: false } + }); + + await paneState.copyBody(); + + assert.deepEqual(writes, ['{\n "model": "gpt-5",\n "stream": false\n}']); + assert.equal(paneState.copyState.copied, true); + assert.equal(paneState.copyState.error, false); + + assert.equal(typeof resetCallback, 'function'); + resetCallback(); + + assert.equal(paneState.copyState.copied, false); + assert.equal(paneState.copyState.error, false); +}); + +test('auditPaneState marks copy failures and clears the error after reset', async () => { + let resetCallback = null; + const module = createAuditListModule({ + console: { + error() {} + }, + setTimeout(callback) { + resetCallback = callback; + return 1; + }, + clearTimeout() {}, + window: { + navigator: { + clipboard: { + writeText() { + return Promise.reject(new Error('denied')); + } + } + } + } + }); + + const paneState = module.auditPaneState({ + copyBody: { id: 'resp_123' } + }); + + await paneState.copyBody(); + + assert.equal(paneState.copyState.copied, false); + assert.equal(paneState.copyState.error, true); + + assert.equal(typeof resetCallback, 'function'); + resetCallback(); + + assert.equal(paneState.copyState.copied, false); + assert.equal(paneState.copyState.error, false); +}); diff --git a/internal/admin/dashboard/static/js/modules/auth-keys.js b/internal/admin/dashboard/static/js/modules/auth-keys.js index 2d8f03a9..5ee6bcec 100644 --- a/internal/admin/dashboard/static/js/modules/auth-keys.js +++ b/internal/admin/dashboard/static/js/modules/auth-keys.js @@ -1,5 +1,12 @@ (function(global) { function dashboardAuthKeysModule() { + const clipboardModuleFactory = typeof global.dashboardClipboardModule === 'function' + ? global.dashboardClipboardModule + : null; + const clipboard = clipboardModuleFactory + ? clipboardModuleFactory() + : null; + return { authKeys: [], authKeysAvailable: true, @@ -10,9 +17,18 @@ authKeyFormSubmitting: false, authKeyIssuedValue: '', authKeyDeactivatingID: '', - authKeyCopied: false, - authKeyCopyError: false, - authKeyCopyResetTimer: null, + authKeyCopyState: clipboard + ? clipboard.createClipboardButtonState({ + logPrefix: 'Failed to copy auth key:' + }) + : { + copied: false, + error: false, + resetFeedback() {}, + copy() { + return Promise.resolve(); + } + }, authKeyForm: { name: '', description: '', @@ -106,7 +122,7 @@ this.authKeyError = ''; this.authKeyNotice = ''; if (!this.authKeyIssuedValue) { - this.resetAuthKeyCopyFeedback(); + this.authKeyCopyState.resetFeedback(); this.authKeyForm = this.defaultAuthKeyForm(); } }, @@ -117,97 +133,19 @@ } this.authKeyFormOpen = false; this.authKeyError = ''; - this.resetAuthKeyCopyFeedback(); + this.authKeyCopyState.resetFeedback(); if (!this.authKeyFormSubmitting && !this.authKeyIssuedValue) { this.authKeyForm = this.defaultAuthKeyForm(); } }, - clearAuthKeyCopyResetTimer() { - if (this.authKeyCopyResetTimer !== null) { - clearTimeout(this.authKeyCopyResetTimer); - this.authKeyCopyResetTimer = null; - } - }, - - scheduleAuthKeyCopyFeedbackReset() { - this.clearAuthKeyCopyResetTimer(); - this.authKeyCopyResetTimer = setTimeout(() => { - this.authKeyCopied = false; - this.authKeyCopyError = false; - this.authKeyCopyResetTimer = null; - }, 2000); - }, - - resetAuthKeyCopyFeedback() { - this.clearAuthKeyCopyResetTimer(); - this.authKeyCopied = false; - this.authKeyCopyError = false; - }, - - setAuthKeyCopyFeedback(copied, hasError) { - this.authKeyCopied = copied; - this.authKeyCopyError = hasError; - this.scheduleAuthKeyCopyFeedbackReset(); - }, - copyAuthKeyValue() { - const value = String(this.authKeyIssuedValue || ''); - const clipboard = global.navigator && global.navigator.clipboard; - - this.resetAuthKeyCopyFeedback(); - - if (clipboard && typeof clipboard.writeText === 'function') { - return clipboard.writeText(value).then(() => { - this.setAuthKeyCopyFeedback(true, false); - }).catch((error) => { - console.error('Failed to copy auth key:', error); - this.setAuthKeyCopyFeedback(false, true); - }); - } - - const doc = global.document; - if (!doc || !doc.body || typeof doc.createElement !== 'function' || typeof doc.execCommand !== 'function') { - this.setAuthKeyCopyFeedback(false, true); - return Promise.resolve(); - } - - const textarea = doc.createElement('textarea'); - textarea.value = value; - textarea.setAttribute('readonly', ''); - textarea.style.position = 'fixed'; - textarea.style.top = '0'; - textarea.style.left = '0'; - textarea.style.opacity = '0'; - - try { - doc.body.appendChild(textarea); - if (typeof textarea.focus === 'function') { - textarea.focus(); - } - if (typeof textarea.select === 'function') { - textarea.select(); - } - if (typeof textarea.setSelectionRange === 'function') { - textarea.setSelectionRange(0, textarea.value.length); - } - const copied = !!doc.execCommand('copy'); - this.setAuthKeyCopyFeedback(copied, !copied); - } catch (error) { - console.error('Failed to copy auth key:', error); - this.setAuthKeyCopyFeedback(false, true); - } finally { - if (textarea.parentNode) { - textarea.parentNode.removeChild(textarea); - } - } - - return Promise.resolve(); + return this.authKeyCopyState.copy(this.authKeyIssuedValue); }, dismissIssuedKey() { this.authKeyIssuedValue = ''; - this.resetAuthKeyCopyFeedback(); + this.authKeyCopyState.resetFeedback(); this.authKeyForm = this.defaultAuthKeyForm(); }, @@ -273,7 +211,7 @@ const issued = await res.json(); this.authKeyIssuedValue = issued.value || ''; this.authKeyFormOpen = true; - this.resetAuthKeyCopyFeedback(); + this.authKeyCopyState.resetFeedback(); this.authKeyForm = this.defaultAuthKeyForm(); await this.fetchAuthKeys(); } catch (e) { diff --git a/internal/admin/dashboard/static/js/modules/auth-keys.test.js b/internal/admin/dashboard/static/js/modules/auth-keys.test.js index 2ee921a7..2c9827a6 100644 --- a/internal/admin/dashboard/static/js/modules/auth-keys.test.js +++ b/internal/admin/dashboard/static/js/modules/auth-keys.test.js @@ -5,6 +5,7 @@ const path = require('node:path'); const vm = require('node:vm'); function loadAuthKeysModuleFactory(overrides = {}) { + const clipboardSource = fs.readFileSync(path.join(__dirname, 'clipboard.js'), 'utf8'); const source = fs.readFileSync(path.join(__dirname, 'auth-keys.js'), 'utf8'); const window = { ...(overrides.window || {}) @@ -17,6 +18,7 @@ function loadAuthKeysModuleFactory(overrides = {}) { window }; vm.createContext(context); + vm.runInContext(clipboardSource, context); vm.runInContext(source, context); return context.window.dashboardAuthKeysModule; } @@ -162,13 +164,13 @@ test('copyAuthKeyValue uses navigator.clipboard when available and resets feedba await module.copyAuthKeyValue(); assert.deepEqual(writes, ['sk_gom_test']); - assert.equal(module.authKeyCopied, true); - assert.equal(module.authKeyCopyError, false); + assert.equal(module.authKeyCopyState.copied, true); + assert.equal(module.authKeyCopyState.error, false); timers.runAll(); - assert.equal(module.authKeyCopied, false); - assert.equal(module.authKeyCopyError, false); + assert.equal(module.authKeyCopyState.copied, false); + assert.equal(module.authKeyCopyState.error, false); }); test('copyAuthKeyValue sets an error flag when navigator.clipboard rejects', async () => { @@ -194,13 +196,13 @@ test('copyAuthKeyValue sets an error flag when navigator.clipboard rejects', asy await module.copyAuthKeyValue(); - assert.equal(module.authKeyCopied, false); - assert.equal(module.authKeyCopyError, true); + assert.equal(module.authKeyCopyState.copied, false); + assert.equal(module.authKeyCopyState.error, true); timers.runAll(); - assert.equal(module.authKeyCopied, false); - assert.equal(module.authKeyCopyError, false); + assert.equal(module.authKeyCopyState.copied, false); + assert.equal(module.authKeyCopyState.error, false); }); test('copyAuthKeyValue falls back to document.execCommand when clipboard API is unavailable', async () => { @@ -250,8 +252,8 @@ test('copyAuthKeyValue falls back to document.execCommand when clipboard API is assert.equal(appended.length, 1); assert.equal(removed.length, 1); assert.equal(appended[0].value, 'sk_gom_test'); - assert.equal(module.authKeyCopied, true); - assert.equal(module.authKeyCopyError, false); + assert.equal(module.authKeyCopyState.copied, true); + assert.equal(module.authKeyCopyState.error, false); }); test('fetchAuthKeys preserves existing rows and surfaces non-auth HTTP errors', async () => { diff --git a/internal/admin/dashboard/static/js/modules/clipboard.js b/internal/admin/dashboard/static/js/modules/clipboard.js new file mode 100644 index 00000000..003ee25e --- /dev/null +++ b/internal/admin/dashboard/static/js/modules/clipboard.js @@ -0,0 +1,131 @@ +(function(global) { + function fallbackClipboardModule(scope) { + function getScheduler(name, fallback) { + if (scope && typeof scope[name] === 'function') { + return scope[name].bind(scope); + } + if (typeof fallback === 'function') { + return fallback; + } + return null; + } + + function writeTextToClipboard(value) { + const payload = String(value == null ? '' : value); + const clipboard = scope.navigator && scope.navigator.clipboard; + + if (clipboard && typeof clipboard.writeText === 'function') { + return clipboard.writeText(payload); + } + + const doc = scope.document; + if (!doc || !doc.body || typeof doc.createElement !== 'function' || typeof doc.execCommand !== 'function') { + return Promise.reject(new Error('Clipboard API unavailable')); + } + + const textarea = doc.createElement('textarea'); + textarea.value = payload; + textarea.setAttribute('readonly', ''); + textarea.style.position = 'fixed'; + textarea.style.top = '0'; + textarea.style.left = '0'; + textarea.style.opacity = '0'; + + try { + doc.body.appendChild(textarea); + if (typeof textarea.focus === 'function') { + textarea.focus(); + } + if (typeof textarea.select === 'function') { + textarea.select(); + } + if (typeof textarea.setSelectionRange === 'function') { + textarea.setSelectionRange(0, textarea.value.length); + } + + if (!doc.execCommand('copy')) { + throw new Error('execCommand copy returned false'); + } + } finally { + if (textarea.parentNode) { + textarea.parentNode.removeChild(textarea); + } + } + + return Promise.resolve(); + } + + function createClipboardButtonState(options = {}) { + const setTimer = getScheduler('setTimeout', typeof setTimeout === 'function' ? setTimeout : null); + const clearTimer = getScheduler('clearTimeout', typeof clearTimeout === 'function' ? clearTimeout : null); + const resetDelayMs = Number.isFinite(options.resetDelayMs) ? options.resetDelayMs : 2000; + + return { + copied: false, + error: false, + resetTimer: null, + + clearResetTimer() { + if (this.resetTimer !== null && clearTimer) { + clearTimer(this.resetTimer); + } + this.resetTimer = null; + }, + + scheduleReset() { + this.clearResetTimer(); + if (!setTimer) { + return; + } + this.resetTimer = setTimer(() => { + this.copied = false; + this.error = false; + this.resetTimer = null; + }, resetDelayMs); + }, + + resetFeedback() { + this.clearResetTimer(); + this.copied = false; + this.error = false; + }, + + setFeedback(copied, error) { + this.copied = copied; + this.error = error; + this.scheduleReset(); + }, + + async copy(value, formatValue) { + if (value == null || value === undefined || value === '') { + return; + } + + this.resetFeedback(); + + try { + const payload = typeof formatValue === 'function' + ? formatValue(value) + : String(value); + await writeTextToClipboard(payload); + this.setFeedback(true, false); + } catch (error) { + console.error(options.logPrefix || 'Failed to copy text:', error); + this.setFeedback(false, true); + } + } + }; + } + + return { + writeTextToClipboard, + createClipboardButtonState + }; + } + + function dashboardClipboardModule() { + return fallbackClipboardModule(global); + } + + global.dashboardClipboardModule = dashboardClipboardModule; +})(window); diff --git a/internal/admin/dashboard/static/js/modules/dashboard-layout.test.js b/internal/admin/dashboard/static/js/modules/dashboard-layout.test.js index 94efa442..7b3cfd67 100644 --- a/internal/admin/dashboard/static/js/modules/dashboard-layout.test.js +++ b/internal/admin/dashboard/static/js/modules/dashboard-layout.test.js @@ -70,6 +70,10 @@ test('dashboard layout pins Chart.js to 4.5.0', () => { template, / +