From df90f6bb37fd2a8d572e6ab563dd00e7bc3e95cf Mon Sep 17 00:00:00 2001 From: ding113 Date: Fri, 24 Jul 2026 21:34:19 -0700 Subject: [PATCH 1/4] style: apply current biome formatting to untouched test files These files predate the locally installed biome version and were reformatted by `bun run lint:fix`. Split out from the TTFB/TFFT change so that diff stays reviewable. No behavioral change. Co-Authored-By: Claude --- src/lib/provider-testing/test-service.test.ts | 50 ++-- .../utils/upstream-error-detection.test.ts | 14 +- .../actions/providers-patch-contract.test.ts | 28 ++- .../api/actions/legacy-deprecation.test.ts | 62 ++--- tests/unit/api/v1/status-code-map.test.ts | 13 +- tests/unit/i18n/key-created-copy.test.ts | 41 ++-- .../instrumentation-crash-handler.test.ts | 26 +- .../lib/provider-allowed-model-schema.test.ts | 23 +- .../probe-scheduler.test.ts | 91 +++---- .../provider-model-redirect-schema.test.ts | 25 +- tests/unit/lib/redis/client.test.ts | 28 +-- .../lib/session-manager-binding-smart.test.ts | 78 +++--- .../upstream-error-detection-status.test.ts | 168 ++++++------- tests/unit/proxy/client-detector.test.ts | 13 +- .../proxy/codex-provider-overrides.test.ts | 102 ++++---- .../connected-non-reader-lifetime.test.ts | 40 ++-- .../proxy/endpoint-family-catalog.test.ts | 11 +- .../endpoint-family-provider-routing.test.ts | 45 ++-- .../proxy/endpoint-path-normalization.test.ts | 20 +- .../error-handler-terminal-status.test.ts | 67 +++--- .../fake-streaming-response-validator.test.ts | 86 ++++--- .../proxy/fake-streaming-response.test.ts | 16 +- .../fake-streaming-stream-intent.test.ts | 150 ++++++------ ...provider-selector-cross-type-model.test.ts | 27 ++- .../proxy-forwarder-endpoint-audit.test.ts | 113 +++++---- .../proxy-forwarder-hedge-first-byte.test.ts | 222 +++++++++--------- .../proxy/proxy-forwarder-retry-limit.test.ts | 110 ++++----- tests/unit/proxy/session.test.ts | 26 +- ...server-response-write-backpressure.test.ts | 88 +++---- 29 files changed, 900 insertions(+), 883 deletions(-) diff --git a/src/lib/provider-testing/test-service.test.ts b/src/lib/provider-testing/test-service.test.ts index 18d093435..49fa29ef6 100644 --- a/src/lib/provider-testing/test-service.test.ts +++ b/src/lib/provider-testing/test-service.test.ts @@ -169,33 +169,33 @@ describe("executeProviderTest", () => { expectRequestUrl("https://relay.example.com/openai/v1/responses"); }); - test.each(["https://api.gptclubapi.xyz/openai", "https://api.gptclubapi.xyz/openai/"])( - "codex bare /openai base preserves absolute versioned request url: %s", - async (providerUrl) => { - mockJsonResponse({ - id: "resp_test", - model: "gpt-5.5", - output: [ - { - type: "message", - role: "assistant", - content: [{ type: "output_text", text: "pong" }], - }, - ], - }); + test.each([ + "https://api.gptclubapi.xyz/openai", + "https://api.gptclubapi.xyz/openai/", + ])("codex bare /openai base preserves absolute versioned request url: %s", async (providerUrl) => { + mockJsonResponse({ + id: "resp_test", + model: "gpt-5.5", + output: [ + { + type: "message", + role: "assistant", + content: [{ type: "output_text", text: "pong" }], + }, + ], + }); - const result = await executeProviderTest({ - providerUrl, - apiKey: "sk-test-codex", - providerType: "codex", - model: "gpt-5.5", - }); + const result = await executeProviderTest({ + providerUrl, + apiKey: "sk-test-codex", + providerType: "codex", + model: "gpt-5.5", + }); - expect(result.success).toBe(true); - expect(result.requestUrl).toBe("https://api.gptclubapi.xyz/openai/v1/responses"); - expect(fetchMock.mock.calls[0]?.[0]).toBe("https://api.gptclubapi.xyz/openai/v1/responses"); - } - ); + expect(result.success).toBe(true); + expect(result.requestUrl).toBe("https://api.gptclubapi.xyz/openai/v1/responses"); + expect(fetchMock.mock.calls[0]?.[0]).toBe("https://api.gptclubapi.xyz/openai/v1/responses"); + }); test("openai-compatible 版本根路径应只追加 endpoint,不重复拼接 /v1", async () => { mockJsonResponse({ diff --git a/src/lib/utils/upstream-error-detection.test.ts b/src/lib/utils/upstream-error-detection.test.ts index 1b35ad6eb..957ef374b 100644 --- a/src/lib/utils/upstream-error-detection.test.ts +++ b/src/lib/utils/upstream-error-detection.test.ts @@ -74,13 +74,13 @@ describe("detectUpstreamErrorFromSseOrJsonText", () => { expect(res.isError).toBe(true); }); - test.each(['{"error":true}', '{"error":42}'])( - "纯 JSON:error 为非字符串类型也应视为错误(%s)", - (body) => { - const res = detectUpstreamErrorFromSseOrJsonText(body); - expect(res.isError).toBe(true); - } - ); + test.each([ + '{"error":true}', + '{"error":42}', + ])("纯 JSON:error 为非字符串类型也应视为错误(%s)", (body) => { + const res = detectUpstreamErrorFromSseOrJsonText(body); + expect(res.isError).toBe(true); + }); test("JSON 数组输入不视为错误(目前不做解析)", () => { const res = detectUpstreamErrorFromSseOrJsonText('[{"error":"something"}]'); diff --git a/tests/unit/actions/providers-patch-contract.test.ts b/tests/unit/actions/providers-patch-contract.test.ts index 3e6a04e23..86274dd20 100644 --- a/tests/unit/actions/providers-patch-contract.test.ts +++ b/tests/unit/actions/providers-patch-contract.test.ts @@ -851,19 +851,21 @@ describe("provider patch contract", () => { }); describe("MCP fields", () => { - it.each(["none", "minimax", "glm", "custom"] as const)( - "accepts mcp_passthrough_type value: %s", - (value) => { - const result = prepareProviderBatchApplyUpdates({ - mcp_passthrough_type: { set: value }, - }); - - expect(result.ok).toBe(true); - if (!result.ok) return; - - expect(result.data.mcp_passthrough_type).toBe(value); - } - ); + it.each([ + "none", + "minimax", + "glm", + "custom", + ] as const)("accepts mcp_passthrough_type value: %s", (value) => { + const result = prepareProviderBatchApplyUpdates({ + mcp_passthrough_type: { set: value }, + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + + expect(result.data.mcp_passthrough_type).toBe(value); + }); it("rejects invalid mcp_passthrough_type value", () => { const result = normalizeProviderBatchPatchDraft({ diff --git a/tests/unit/api/actions/legacy-deprecation.test.ts b/tests/unit/api/actions/legacy-deprecation.test.ts index f1740c44c..4292eac45 100644 --- a/tests/unit/api/actions/legacy-deprecation.test.ts +++ b/tests/unit/api/actions/legacy-deprecation.test.ts @@ -75,20 +75,20 @@ describe("legacy actions API deprecation", () => { expectManagementSecurityHeaders(response); }); - test.each(["/api/actions/docs", "/api/actions/scalar"])( - "keeps legacy docs UI %s available when execution is disabled but docs mode is deprecated", - async (pathname) => { - vi.stubEnv("ENABLE_LEGACY_ACTIONS_API", "false"); - vi.stubEnv("LEGACY_ACTIONS_DOCS_MODE", "deprecated"); - - const response = await callFreshActionsRoute(pathname, "GET"); - - expect(response.status).toBe(200); - expect(response.headers.get("Deprecation")).toBe("@1777420800"); - expect(response.headers.get("Link")).toContain("/api/v1/openapi.json"); - expectManagementSecurityHeaders(response); - } - ); + test.each([ + "/api/actions/docs", + "/api/actions/scalar", + ])("keeps legacy docs UI %s available when execution is disabled but docs mode is deprecated", async (pathname) => { + vi.stubEnv("ENABLE_LEGACY_ACTIONS_API", "false"); + vi.stubEnv("LEGACY_ACTIONS_DOCS_MODE", "deprecated"); + + const response = await callFreshActionsRoute(pathname, "GET"); + + expect(response.status).toBe(200); + expect(response.headers.get("Deprecation")).toBe("@1777420800"); + expect(response.headers.get("Link")).toContain("/api/v1/openapi.json"); + expectManagementSecurityHeaders(response); + }); test("keeps deprecation date stable when sunset date is overridden", async () => { vi.stubEnv("LEGACY_ACTIONS_SUNSET_DATE", "2027-01-15"); @@ -114,21 +114,21 @@ describe("legacy actions API deprecation", () => { }); }); - test.each(["/api/actions/docs", "/api/actions/scalar"])( - "can hide legacy docs UI %s independently with the docs mode flag", - async (pathname) => { - vi.stubEnv("ENABLE_LEGACY_ACTIONS_API", "true"); - vi.stubEnv("LEGACY_ACTIONS_DOCS_MODE", "hidden"); - - const response = await callFreshActionsRoute(pathname, "GET"); - const body = await response.json(); - - expect(response.status).toBe(410); - expect(body).toMatchObject({ - status: 410, - errorCode: "api.legacy_actions_gone", - instance: pathname, - }); - } - ); + test.each([ + "/api/actions/docs", + "/api/actions/scalar", + ])("can hide legacy docs UI %s independently with the docs mode flag", async (pathname) => { + vi.stubEnv("ENABLE_LEGACY_ACTIONS_API", "true"); + vi.stubEnv("LEGACY_ACTIONS_DOCS_MODE", "hidden"); + + const response = await callFreshActionsRoute(pathname, "GET"); + const body = await response.json(); + + expect(response.status).toBe(410); + expect(body).toMatchObject({ + status: 410, + errorCode: "api.legacy_actions_gone", + instance: pathname, + }); + }); }); diff --git a/tests/unit/api/v1/status-code-map.test.ts b/tests/unit/api/v1/status-code-map.test.ts index ec62a2e57..e38074a4b 100644 --- a/tests/unit/api/v1/status-code-map.test.ts +++ b/tests/unit/api/v1/status-code-map.test.ts @@ -15,11 +15,10 @@ describe("v1 status code map", () => { [415, "Unsupported media type", "request.unsupported_media_type"], [429, "Too many requests", "rate_limit.exceeded"], [503, "Service unavailable", "dependency.unavailable"], - ] as Array<[ProblemStatusCode, string, string]>)( - "maps %s to defaults", - (status, title, errorCode) => { - expect(getDefaultProblemTitle(status)).toBe(title); - expect(getDefaultErrorCode(status)).toBe(errorCode); - } - ); + ] as Array< + [ProblemStatusCode, string, string] + >)("maps %s to defaults", (status, title, errorCode) => { + expect(getDefaultProblemTitle(status)).toBe(title); + expect(getDefaultErrorCode(status)).toBe(errorCode); + }); }); diff --git a/tests/unit/i18n/key-created-copy.test.ts b/tests/unit/i18n/key-created-copy.test.ts index 1ba546dfb..5bcdaf7c0 100644 --- a/tests/unit/i18n/key-created-copy.test.ts +++ b/tests/unit/i18n/key-created-copy.test.ts @@ -65,32 +65,31 @@ function getString(messages: Record, keyPath: readonly string[] describe.each(LOCALES)("key creation copy (%s)", (locale) => { const dashboard = loadMessages(locale, "dashboard.json"); - test.each(COPY_PATHS.map((p) => [p.join("."), p] as const))( - "%s matches the actual reveal behavior", - (_label, keyPath) => { - const copy = getString(dashboard, keyPath); + test.each( + COPY_PATHS.map((p) => [p.join("."), p] as const) + )("%s matches the actual reveal behavior", (_label, keyPath) => { + const copy = getString(dashboard, keyPath); - expect(copy.trim().length).toBeGreaterThan(0); - for (const pattern of ONE_TIME_CLAIM_PATTERNS) { - expect(copy).not.toMatch(pattern); - } - expect(copy).toMatch(REVIEWABLE_MARKERS[locale]); + expect(copy.trim().length).toBeGreaterThan(0); + for (const pattern of ONE_TIME_CLAIM_PATTERNS) { + expect(copy).not.toMatch(pattern); } - ); + expect(copy).toMatch(REVIEWABLE_MARKERS[locale]); + }); }); describe.each(LOCALES)("removeKey error code translations (%s)", (locale) => { const errors = loadMessages(locale, "errors.json"); - test.each(["CANNOT_DELETE_LAST_KEY", "CANNOT_DELETE_LAST_GROUP_KEY"])( - "errors namespace translates %s", - (code) => { - const value = errors[code]; - expect(value, `${locale}/errors.json must define ${code}`).toBeTypeOf("string"); - expect((value as string).trim().length).toBeGreaterThan(0); - // Must be a distinct, specific message rather than a copy of a generic one. - expect(value).not.toBe(errors.OPERATION_FAILED); - expect(value).not.toBe(errors.DELETE_FAILED); - } - ); + test.each([ + "CANNOT_DELETE_LAST_KEY", + "CANNOT_DELETE_LAST_GROUP_KEY", + ])("errors namespace translates %s", (code) => { + const value = errors[code]; + expect(value, `${locale}/errors.json must define ${code}`).toBeTypeOf("string"); + expect((value as string).trim().length).toBeGreaterThan(0); + // Must be a distinct, specific message rather than a copy of a generic one. + expect(value).not.toBe(errors.OPERATION_FAILED); + expect(value).not.toBe(errors.DELETE_FAILED); + }); }); diff --git a/tests/unit/instrumentation-crash-handler.test.ts b/tests/unit/instrumentation-crash-handler.test.ts index b195e69b4..256a9ea82 100644 --- a/tests/unit/instrumentation-crash-handler.test.ts +++ b/tests/unit/instrumentation-crash-handler.test.ts @@ -171,19 +171,19 @@ describe("registerCrashDiagnostics", () => { expect(logger.fatal).toHaveBeenCalledTimes(1); }); - it.each(["ECONNRESET", "ERR_STREAM_PREMATURE_CLOSE"])( - "uncaughtException: ambiguous code %s is NOT suppressed and still exits with code 1", - (code) => { - // 这些码方向不明(可能来自上游 DB/Redis/provider),进程级无上下文区分, - // 必须保持 fail-fast,避免误吞真正的基础设施故障。 - const { uncaughtException } = captureHandlers(); - uncaughtException(makeError(code)); - - expect(exitSpy).toHaveBeenCalledWith(1); - expect(logger.fatal).toHaveBeenCalledTimes(1); - expect(logger.warn).not.toHaveBeenCalled(); - } - ); + it.each([ + "ECONNRESET", + "ERR_STREAM_PREMATURE_CLOSE", + ])("uncaughtException: ambiguous code %s is NOT suppressed and still exits with code 1", (code) => { + // 这些码方向不明(可能来自上游 DB/Redis/provider),进程级无上下文区分, + // 必须保持 fail-fast,避免误吞真正的基础设施故障。 + const { uncaughtException } = captureHandlers(); + uncaughtException(makeError(code)); + + expect(exitSpy).toHaveBeenCalledWith(1); + expect(logger.fatal).toHaveBeenCalledTimes(1); + expect(logger.warn).not.toHaveBeenCalled(); + }); it("unhandledRejection: a generic rejection exits with code 1", () => { const { unhandledRejection } = captureHandlers(); diff --git a/tests/unit/lib/provider-allowed-model-schema.test.ts b/tests/unit/lib/provider-allowed-model-schema.test.ts index 44c2deddc..9787daab8 100644 --- a/tests/unit/lib/provider-allowed-model-schema.test.ts +++ b/tests/unit/lib/provider-allowed-model-schema.test.ts @@ -51,17 +51,20 @@ describe("provider-allowed-model-schema", () => { }); describe("regex 模式的 glob 通配符兼容", () => { - it.each<[string]>([["*"], ["*."], ["claude-*"], ["*-opus-*"], ["?"]])( - "接受 glob 风格的 pattern: %s", - (pattern) => { - const result = PROVIDER_ALLOWED_MODEL_RULE_SCHEMA.safeParse({ - matchType: "regex", - pattern, - }); + it.each<[string]>([ + ["*"], + ["*."], + ["claude-*"], + ["*-opus-*"], + ["?"], + ])("接受 glob 风格的 pattern: %s", (pattern) => { + const result = PROVIDER_ALLOWED_MODEL_RULE_SCHEMA.safeParse({ + matchType: "regex", + pattern, + }); - expect(result.success).toBe(true); - } - ); + expect(result.success).toBe(true); + }); it("仍然拒绝纯粹无法解析的正则", () => { const result = PROVIDER_ALLOWED_MODEL_RULE_SCHEMA.safeParse({ diff --git a/tests/unit/lib/provider-endpoints/probe-scheduler.test.ts b/tests/unit/lib/provider-endpoints/probe-scheduler.test.ts index f2142995e..9409f7b23 100644 --- a/tests/unit/lib/provider-endpoints/probe-scheduler.test.ts +++ b/tests/unit/lib/provider-endpoints/probe-scheduler.test.ts @@ -119,51 +119,54 @@ describe("provider-endpoints: probe scheduler", () => { expect(loggerWarnMock).not.toHaveBeenCalled(); }); - test.each(["enabled", "TRUE", " false "])( - "invalid scheduler switch %s warns and falls back to enabled", - async (value) => { - vi.resetModules(); - vi.stubEnv("ENDPOINT_PROBE_SCHEDULER_ENABLED", value); - vi.stubEnv("ENDPOINT_PROBE_TIMEOUT_RETRY_INTERVAL_MS", undefined); - - const { getEndpointProbeSchedulerStatus } = await import( - "@/lib/provider-endpoints/probe-scheduler" - ); - - expect(getEndpointProbeSchedulerStatus().enabled).toBe(true); - expect(loggerWarnMock).toHaveBeenCalledWith( - "[EndpointProbeScheduler] Invalid environment variable, using default", - { - name: "ENDPOINT_PROBE_SCHEDULER_ENABLED", - value, - defaultValue: true, - } - ); - } - ); + test.each([ + "enabled", + "TRUE", + " false ", + ])("invalid scheduler switch %s warns and falls back to enabled", async (value) => { + vi.resetModules(); + vi.stubEnv("ENDPOINT_PROBE_SCHEDULER_ENABLED", value); + vi.stubEnv("ENDPOINT_PROBE_TIMEOUT_RETRY_INTERVAL_MS", undefined); - test.each(["10000ms", "0", "-1", "1.5"])( - "invalid timeout retry interval %s warns and falls back to 10000", - async (value) => { - vi.resetModules(); - vi.stubEnv("ENDPOINT_PROBE_SCHEDULER_ENABLED", undefined); - vi.stubEnv("ENDPOINT_PROBE_TIMEOUT_RETRY_INTERVAL_MS", value); - - const { getEndpointProbeSchedulerStatus } = await import( - "@/lib/provider-endpoints/probe-scheduler" - ); - - expect(getEndpointProbeSchedulerStatus().timeoutOverrideIntervalMs).toBe(10_000); - expect(loggerWarnMock).toHaveBeenCalledWith( - "[EndpointProbeScheduler] Invalid environment variable, using default", - { - name: "ENDPOINT_PROBE_TIMEOUT_RETRY_INTERVAL_MS", - value, - defaultValue: 10_000, - } - ); - } - ); + const { getEndpointProbeSchedulerStatus } = await import( + "@/lib/provider-endpoints/probe-scheduler" + ); + + expect(getEndpointProbeSchedulerStatus().enabled).toBe(true); + expect(loggerWarnMock).toHaveBeenCalledWith( + "[EndpointProbeScheduler] Invalid environment variable, using default", + { + name: "ENDPOINT_PROBE_SCHEDULER_ENABLED", + value, + defaultValue: true, + } + ); + }); + + test.each([ + "10000ms", + "0", + "-1", + "1.5", + ])("invalid timeout retry interval %s warns and falls back to 10000", async (value) => { + vi.resetModules(); + vi.stubEnv("ENDPOINT_PROBE_SCHEDULER_ENABLED", undefined); + vi.stubEnv("ENDPOINT_PROBE_TIMEOUT_RETRY_INTERVAL_MS", value); + + const { getEndpointProbeSchedulerStatus } = await import( + "@/lib/provider-endpoints/probe-scheduler" + ); + + expect(getEndpointProbeSchedulerStatus().timeoutOverrideIntervalMs).toBe(10_000); + expect(loggerWarnMock).toHaveBeenCalledWith( + "[EndpointProbeScheduler] Invalid environment variable, using default", + { + name: "ENDPOINT_PROBE_TIMEOUT_RETRY_INTERVAL_MS", + value, + defaultValue: 10_000, + } + ); + }); test("disabled scheduler does not create a timer, acquire a lock, or query endpoints", async () => { vi.resetModules(); diff --git a/tests/unit/lib/provider-model-redirect-schema.test.ts b/tests/unit/lib/provider-model-redirect-schema.test.ts index 3766b181f..a29461437 100644 --- a/tests/unit/lib/provider-model-redirect-schema.test.ts +++ b/tests/unit/lib/provider-model-redirect-schema.test.ts @@ -53,18 +53,21 @@ describe("provider-model-redirect-schema", () => { }); describe("regex 模式的 glob 通配符兼容", () => { - it.each<[string]>([["*"], ["*."], ["claude-*"], ["*-opus-*"], ["?"]])( - "接受 glob 风格的 source: %s", - (source) => { - const result = PROVIDER_MODEL_REDIRECT_RULE_SCHEMA.safeParse({ - matchType: "regex", - source, - target: "claude-sonnet-4-6", - }); + it.each<[string]>([ + ["*"], + ["*."], + ["claude-*"], + ["*-opus-*"], + ["?"], + ])("接受 glob 风格的 source: %s", (source) => { + const result = PROVIDER_MODEL_REDIRECT_RULE_SCHEMA.safeParse({ + matchType: "regex", + source, + target: "claude-sonnet-4-6", + }); - expect(result.success).toBe(true); - } - ); + expect(result.success).toBe(true); + }); it("仍然拒绝纯粹无法解析的正则", () => { const result = PROVIDER_MODEL_REDIRECT_RULE_SCHEMA.safeParse({ diff --git a/tests/unit/lib/redis/client.test.ts b/tests/unit/lib/redis/client.test.ts index 4553739d5..4f10892a1 100644 --- a/tests/unit/lib/redis/client.test.ts +++ b/tests/unit/lib/redis/client.test.ts @@ -61,20 +61,20 @@ describe("buildRedisOptionsForUrl", () => { expect(result.isTLS).toBe(true); }); - it.each(["redis://localhost:6379", "rediss://localhost:6380"])( - "supports REDIS_COMMAND_TIMEOUT_MS override for %s", - async (redisUrl) => { - process.env.REDIS_COMMAND_TIMEOUT_MS = "2500"; - vi.resetModules(); - const { buildRedisOptionsForUrl: buildFreshOptions } = await import("@/lib/redis/client"); - - const result = buildFreshOptions(redisUrl); - - expect(result.options.commandTimeout).toBe(2_500); - expect(result.options.socketTimeout).toBe(7_500); - expect(result.options.autoResendUnfulfilledCommands).toBe(false); - } - ); + it.each([ + "redis://localhost:6379", + "rediss://localhost:6380", + ])("supports REDIS_COMMAND_TIMEOUT_MS override for %s", async (redisUrl) => { + process.env.REDIS_COMMAND_TIMEOUT_MS = "2500"; + vi.resetModules(); + const { buildRedisOptionsForUrl: buildFreshOptions } = await import("@/lib/redis/client"); + + const result = buildFreshOptions(redisUrl); + + expect(result.options.commandTimeout).toBe(2_500); + expect(result.options.socketTimeout).toBe(7_500); + expect(result.options.autoResendUnfulfilledCommands).toBe(false); + }); }); describe("getRedisClient", () => { diff --git a/tests/unit/lib/session-manager-binding-smart.test.ts b/tests/unit/lib/session-manager-binding-smart.test.ts index dbc0855b7..3244633ca 100644 --- a/tests/unit/lib/session-manager-binding-smart.test.ts +++ b/tests/unit/lib/session-manager-binding-smart.test.ts @@ -319,45 +319,45 @@ describe("SessionManager.updateSessionBindingSmart forceUpdate", () => { it.each([ { isFailoverSuccess: false, forceUpdate: true }, { isFailoverSuccess: true, forceUpdate: false }, - ])( - "does not let a stale versioned winner overwrite a newer binding (%o)", - async ({ isFailoverSuccess, forceUpdate }) => { - bindingMocks.readOrReconcileSessionBinding.mockResolvedValue({ - status: "ok", - source: "existing", - snapshot: { - sessionId: SID, - keyId: KEY_ID, - providerId: 1, - generation: "generation-before-concurrent-update", - }, - legacyFallbackAllowed: false, - }); - bindingMocks.compareAndSetSessionBinding.mockResolvedValue({ - status: "conflict", - reason: "generation_mismatch", - legacyFallbackAllowed: false, - }); - - const result = await SessionManager.updateSessionBindingSmart( - SID, - 2, - 10, - false, - isFailoverSuccess, - KEY_ID, - forceUpdate - ); - - expect(result).toEqual({ - updated: false, - reason: "concurrent_binding_changed", - details: "Session binding changed before the update committed", - }); - expect(bindingMocks.compareAndSetSessionBinding).toHaveBeenCalledTimes(1); - expect(bindingMocks.mutateLegacySessionBindingSafely).not.toHaveBeenCalled(); - } - ); + ])("does not let a stale versioned winner overwrite a newer binding (%o)", async ({ + isFailoverSuccess, + forceUpdate, + }) => { + bindingMocks.readOrReconcileSessionBinding.mockResolvedValue({ + status: "ok", + source: "existing", + snapshot: { + sessionId: SID, + keyId: KEY_ID, + providerId: 1, + generation: "generation-before-concurrent-update", + }, + legacyFallbackAllowed: false, + }); + bindingMocks.compareAndSetSessionBinding.mockResolvedValue({ + status: "conflict", + reason: "generation_mismatch", + legacyFallbackAllowed: false, + }); + + const result = await SessionManager.updateSessionBindingSmart( + SID, + 2, + 10, + false, + isFailoverSuccess, + KEY_ID, + forceUpdate + ); + + expect(result).toEqual({ + updated: false, + reason: "concurrent_binding_changed", + details: "Session binding changed before the update committed", + }); + expect(bindingMocks.compareAndSetSessionBinding).toHaveBeenCalledTimes(1); + expect(bindingMocks.mutateLegacySessionBindingSafely).not.toHaveBeenCalled(); + }); it("does not fall back to legacy writes for a foreign owner conflict", async () => { bindingMocks.readOrReconcileSessionBinding.mockResolvedValue({ diff --git a/tests/unit/lib/upstream-error-detection-status.test.ts b/tests/unit/lib/upstream-error-detection-status.test.ts index 2bac09c5c..e34cf75a5 100644 --- a/tests/unit/lib/upstream-error-detection-status.test.ts +++ b/tests/unit/lib/upstream-error-detection-status.test.ts @@ -28,87 +28,93 @@ const cloudflareErrorCases = [ ] as const; describe("inferUpstreamErrorStatusCodeFromText numeric boundaries", () => { - it.each(httpStatusCases)( - "keeps matching a standalone HTTP $statusCode status token", - ({ statusCode, matcherId }) => { - expect(inferUpstreamErrorStatusCodeFromText(`HTTP/1.1 ${statusCode}`)).toEqual({ - statusCode, - matcherId, - }); - } - ); - - it.each(httpStatusCases)( - "does not treat HTTP $statusCode followed by a decimal fraction as a status token", - ({ statusCode }) => { - expect(inferUpstreamErrorStatusCodeFromText(`HTTP/1.1 ${statusCode}.12`)).toBeNull(); - } - ); - - it.each(httpStatusCases)( - "does not treat HTTP $statusCode embedded in a longer number as a status token", - ({ statusCode }) => { - expect(inferUpstreamErrorStatusCodeFromText(`HTTP/1.1 ${statusCode}12`)).toBeNull(); - } - ); - - it.each(httpStatusCases)( - "does not treat HTTP $statusCode followed by a letter as a status token", - ({ statusCode }) => { - expect(inferUpstreamErrorStatusCodeFromText(`HTTP/1.1 ${statusCode}abc`)).toBeNull(); - } - ); - - it.each(httpStatusCases)( - "keeps matching HTTP $statusCode followed by sentence punctuation", - ({ statusCode, matcherId }) => { - expect(inferUpstreamErrorStatusCodeFromText(`HTTP/1.1 ${statusCode}.`)).toEqual({ - statusCode, - matcherId, - }); - } - ); - - it.each(cloudflareErrorCases)( - "keeps matching a standalone Cloudflare Error $code token", - ({ code, statusCode, matcherId }) => { - expect(inferUpstreamErrorStatusCodeFromText(`Error ${code}`)).toEqual({ - statusCode, - matcherId, - }); - } - ); - - it.each(cloudflareErrorCases)( - "does not treat Cloudflare Error $code followed by a decimal fraction as a code token", - ({ code }) => { - expect(inferUpstreamErrorStatusCodeFromText(`Error ${code}.7`)).toBeNull(); - } - ); - - it.each(cloudflareErrorCases)( - "does not treat Cloudflare Error $code embedded in a longer number as a code token", - ({ code }) => { - expect(inferUpstreamErrorStatusCodeFromText(`Error ${code}7`)).toBeNull(); - } - ); - - it.each(cloudflareErrorCases)( - "does not treat Cloudflare Error $code followed by a letter as a code token", - ({ code }) => { - expect(inferUpstreamErrorStatusCodeFromText(`Error ${code}x`)).toBeNull(); - } - ); - - it.each(cloudflareErrorCases)( - "keeps matching Cloudflare Error $code followed by sentence punctuation", - ({ code, statusCode, matcherId }) => { - expect(inferUpstreamErrorStatusCodeFromText(`Error ${code}.`)).toEqual({ - statusCode, - matcherId, - }); - } - ); + it.each(httpStatusCases)("keeps matching a standalone HTTP $statusCode status token", ({ + statusCode, + matcherId, + }) => { + expect(inferUpstreamErrorStatusCodeFromText(`HTTP/1.1 ${statusCode}`)).toEqual({ + statusCode, + matcherId, + }); + }); + + it.each( + httpStatusCases + )("does not treat HTTP $statusCode followed by a decimal fraction as a status token", ({ + statusCode, + }) => { + expect(inferUpstreamErrorStatusCodeFromText(`HTTP/1.1 ${statusCode}.12`)).toBeNull(); + }); + + it.each( + httpStatusCases + )("does not treat HTTP $statusCode embedded in a longer number as a status token", ({ + statusCode, + }) => { + expect(inferUpstreamErrorStatusCodeFromText(`HTTP/1.1 ${statusCode}12`)).toBeNull(); + }); + + it.each( + httpStatusCases + )("does not treat HTTP $statusCode followed by a letter as a status token", ({ statusCode }) => { + expect(inferUpstreamErrorStatusCodeFromText(`HTTP/1.1 ${statusCode}abc`)).toBeNull(); + }); + + it.each(httpStatusCases)("keeps matching HTTP $statusCode followed by sentence punctuation", ({ + statusCode, + matcherId, + }) => { + expect(inferUpstreamErrorStatusCodeFromText(`HTTP/1.1 ${statusCode}.`)).toEqual({ + statusCode, + matcherId, + }); + }); + + it.each(cloudflareErrorCases)("keeps matching a standalone Cloudflare Error $code token", ({ + code, + statusCode, + matcherId, + }) => { + expect(inferUpstreamErrorStatusCodeFromText(`Error ${code}`)).toEqual({ + statusCode, + matcherId, + }); + }); + + it.each( + cloudflareErrorCases + )("does not treat Cloudflare Error $code followed by a decimal fraction as a code token", ({ + code, + }) => { + expect(inferUpstreamErrorStatusCodeFromText(`Error ${code}.7`)).toBeNull(); + }); + + it.each( + cloudflareErrorCases + )("does not treat Cloudflare Error $code embedded in a longer number as a code token", ({ + code, + }) => { + expect(inferUpstreamErrorStatusCodeFromText(`Error ${code}7`)).toBeNull(); + }); + + it.each( + cloudflareErrorCases + )("does not treat Cloudflare Error $code followed by a letter as a code token", ({ code }) => { + expect(inferUpstreamErrorStatusCodeFromText(`Error ${code}x`)).toBeNull(); + }); + + it.each( + cloudflareErrorCases + )("keeps matching Cloudflare Error $code followed by sentence punctuation", ({ + code, + statusCode, + matcherId, + }) => { + expect(inferUpstreamErrorStatusCodeFromText(`Error ${code}.`)).toEqual({ + statusCode, + matcherId, + }); + }); it("does not infer service_unavailable from an AWS request id containing 503", () => { const text = "request id: 202604250550399959"; diff --git a/tests/unit/proxy/client-detector.test.ts b/tests/unit/proxy/client-detector.test.ts index ee43c028e..d14dabc6f 100644 --- a/tests/unit/proxy/client-detector.test.ts +++ b/tests/unit/proxy/client-detector.test.ts @@ -85,12 +85,13 @@ describe("client-detector", () => { expect(isBuiltinKeyword(pattern)).toBe(true); }); - test.each(["gemini-cli", "codex-cli", "custom-pattern"])( - "should return false for non-builtin keyword: %s", - (pattern) => { - expect(isBuiltinKeyword(pattern)).toBe(false); - } - ); + test.each([ + "gemini-cli", + "codex-cli", + "custom-pattern", + ])("should return false for non-builtin keyword: %s", (pattern) => { + expect(isBuiltinKeyword(pattern)).toBe(false); + }); }); describe("confirmClaudeCodeSignals via detectClientFull", () => { diff --git a/tests/unit/proxy/codex-provider-overrides.test.ts b/tests/unit/proxy/codex-provider-overrides.test.ts index 6f668bc45..c233725b4 100644 --- a/tests/unit/proxy/codex-provider-overrides.test.ts +++ b/tests/unit/proxy/codex-provider-overrides.test.ts @@ -259,38 +259,35 @@ describe("Codex 供应商级参数覆写", () => { ], }, ], - ])( - "当强制 image_generation=true 且%s已声明 namespace 时,allowed_tools 应使用同形引用", - (_, request) => { - const provider = { - providerType: "codex", - codexImageGenerationPreference: "true", - }; - const input: Record = { - model: "gpt-5.5", - ...request, - tool_choice: { - type: "allowed_tools", - mode: "auto", - tools: [{ type: "function", name: "lookup_weather" }], - }, - }; - - const output = applyCodexProviderOverrides(provider as any, input); - - expect(output.tool_choice).toEqual({ + ])("当强制 image_generation=true 且%s已声明 namespace 时,allowed_tools 应使用同形引用", (_, request) => { + const provider = { + providerType: "codex", + codexImageGenerationPreference: "true", + }; + const input: Record = { + model: "gpt-5.5", + ...request, + tool_choice: { type: "allowed_tools", mode: "auto", - tools: [ - { type: "function", name: "lookup_weather" }, - { type: "namespace", name: "image_gen" }, - ], - }); - expect(output.tools).not.toEqual( - expect.arrayContaining([expect.objectContaining({ type: "image_generation" })]) - ); - } - ); + tools: [{ type: "function", name: "lookup_weather" }], + }, + }; + + const output = applyCodexProviderOverrides(provider as any, input); + + expect(output.tool_choice).toEqual({ + type: "allowed_tools", + mode: "auto", + tools: [ + { type: "function", name: "lookup_weather" }, + { type: "namespace", name: "image_gen" }, + ], + }); + expect(output.tools).not.toEqual( + expect.arrayContaining([expect.objectContaining({ type: "image_generation" })]) + ); + }); it("当强制 image_generation=false 时,应从 tools 中移除对应工具", () => { const provider = { @@ -428,30 +425,27 @@ describe("Codex 供应商级参数覆写", () => { ["字符串", "image_generation", "image_generation"], ["namespace 字段", { type: "namespace", namespace: "image_gen" }, "namespace:image_gen"], ["嵌套 tool", { tool: { type: "namespace", name: "image_gen" } }, "tool:image_generation"], - ])( - "当强制 image_generation=false 时,应移除%s形式的 tool_choice 并记录审计", - (_, toolChoice, auditValue) => { - const provider = { - providerType: "codex", - codexImageGenerationPreference: "false", - }; - const input: Record = { - model: "gpt-5.5", - input: [], - tool_choice: toolChoice, - }; - - const result = applyCodexProviderOverridesWithAudit(provider as any, input); - - expect(result.request.tool_choice).toBeUndefined(); - expect(result.audit?.changes.find((change) => change.path === "tool_choice")).toEqual({ - path: "tool_choice", - before: auditValue, - after: null, - changed: true, - }); - } - ); + ])("当强制 image_generation=false 时,应移除%s形式的 tool_choice 并记录审计", (_, toolChoice, auditValue) => { + const provider = { + providerType: "codex", + codexImageGenerationPreference: "false", + }; + const input: Record = { + model: "gpt-5.5", + input: [], + tool_choice: toolChoice, + }; + + const result = applyCodexProviderOverridesWithAudit(provider as any, input); + + expect(result.request.tool_choice).toBeUndefined(); + expect(result.audit?.changes.find((change) => change.path === "tool_choice")).toEqual({ + path: "tool_choice", + before: auditValue, + after: null, + changed: true, + }); + }); it("不应把名为 image_generation 的普通函数选择误判为内置图片工具", () => { const provider = { diff --git a/tests/unit/proxy/connected-non-reader-lifetime.test.ts b/tests/unit/proxy/connected-non-reader-lifetime.test.ts index f41e2a7c6..30862f7d9 100644 --- a/tests/unit/proxy/connected-non-reader-lifetime.test.ts +++ b/tests/unit/proxy/connected-non-reader-lifetime.test.ts @@ -212,27 +212,27 @@ describe("connected non-reader response lifetime", () => { expect(settlements.every((settlement) => settlement.status === "fulfilled")).toBe(true); }); - it.each([true, false])( - "detaches client cancellation after headers with signal=%s", - async (hasClientSignal) => { - const clientController = new AbortController(); - const session = await createGeminiSession(hasClientSignal ? clientController.signal : null); - let transportSignal: AbortSignal | undefined; - transportMocks.request.mockImplementation(async (_url, options) => { - transportSignal = options.signal; - return { - statusCode: 200, - headers: { "content-type": "text/event-stream" }, - body: Readable.from(["data: {}\n\n"]), - }; - }); + it.each([ + true, + false, + ])("detaches client cancellation after headers with signal=%s", async (hasClientSignal) => { + const clientController = new AbortController(); + const session = await createGeminiSession(hasClientSignal ? clientController.signal : null); + let transportSignal: AbortSignal | undefined; + transportMocks.request.mockImplementation(async (_url, options) => { + transportSignal = options.signal; + return { + statusCode: 200, + headers: { "content-type": "text/event-stream" }, + body: Readable.from(["data: {}\n\n"]), + }; + }); - const response = await ProxyForwarder.send(session); - clientController.abort(new Error("client disconnected after headers")); - expect(transportSignal?.aborted).toBe(false); - await response.body?.cancel(); - } - ); + const response = await ProxyForwarder.send(session); + clientController.abort(new Error("client disconnected after headers")); + expect(transportSignal?.aborted).toBe(false); + await response.body?.cancel(); + }); it("detaches transport signals after an upstream error response", async () => { const clientController = new AbortController(); diff --git a/tests/unit/proxy/endpoint-family-catalog.test.ts b/tests/unit/proxy/endpoint-family-catalog.test.ts index efd8ea69c..428fe937c 100644 --- a/tests/unit/proxy/endpoint-family-catalog.test.ts +++ b/tests/unit/proxy/endpoint-family-catalog.test.ts @@ -352,12 +352,11 @@ describe("endpoint family catalog", () => { expect(resolveEndpointFamilyByPath(path)?.modelRequired).toBe(true); }); - test.each(FAMILY_SAMPLES.filter((entry) => !entry.modelRequired))( - "%s 不应要求模型", - ({ path }) => { - expect(resolveEndpointFamilyByPath(path)?.modelRequired).toBe(false); - } - ); + test.each(FAMILY_SAMPLES.filter((entry) => !entry.modelRequired))("%s 不应要求模型", ({ + path, + }) => { + expect(resolveEndpointFamilyByPath(path)?.modelRequired).toBe(false); + }); test("Gemini batch body fallback 应识别为 gemini", () => { expect( diff --git a/tests/unit/proxy/endpoint-family-provider-routing.test.ts b/tests/unit/proxy/endpoint-family-provider-routing.test.ts index 4722353fd..14d121fb1 100644 --- a/tests/unit/proxy/endpoint-family-provider-routing.test.ts +++ b/tests/unit/proxy/endpoint-family-provider-routing.test.ts @@ -385,31 +385,32 @@ describe("endpoint family -> provider routing matrix", () => { ); }); - test.each(ENDPOINT_PROVIDER_CASES)( - "$id should route $path to $expectedProviderType", - async ({ path, expectedProviderType, requestedModel }) => { - const ProxyProviderResolver = await setupResolverMocks(); + test.each(ENDPOINT_PROVIDER_CASES)("$id should route $path to $expectedProviderType", async ({ + path, + expectedProviderType, + requestedModel, + }) => { + const ProxyProviderResolver = await setupResolverMocks(); - const providers: Provider[] = [ - createTestProvider(1, "claude"), - createTestProvider(2, "claude-auth"), - createTestProvider(3, "codex"), - createTestProvider(4, "openai-compatible"), - createTestProvider(5, "gemini"), - createTestProvider(6, "gemini-cli"), - ]; - const session = createSessionStub(path, requestedModel); - session.getProvidersSnapshot = async () => providers; + const providers: Provider[] = [ + createTestProvider(1, "claude"), + createTestProvider(2, "claude-auth"), + createTestProvider(3, "codex"), + createTestProvider(4, "openai-compatible"), + createTestProvider(5, "gemini"), + createTestProvider(6, "gemini-cli"), + ]; + const session = createSessionStub(path, requestedModel); + session.getProvidersSnapshot = async () => providers; - const { provider, context } = await (ProxyProviderResolver as any).pickRandomProvider( - session, - [] - ); + const { provider, context } = await (ProxyProviderResolver as any).pickRandomProvider( + session, + [] + ); - expect(provider?.providerType).toBe(expectedProviderType); - expect(context.requestedModel).toBe(requestedModel); - } - ); + expect(provider?.providerType).toBe(expectedProviderType); + expect(context.requestedModel).toBe(requestedModel); + }); test("/v1/chat/completions should never select codex when openai-compatible is available", async () => { const ProxyProviderResolver = await setupResolverMocks(); diff --git a/tests/unit/proxy/endpoint-path-normalization.test.ts b/tests/unit/proxy/endpoint-path-normalization.test.ts index 183585a7b..8b4662e04 100644 --- a/tests/unit/proxy/endpoint-path-normalization.test.ts +++ b/tests/unit/proxy/endpoint-path-normalization.test.ts @@ -38,15 +38,17 @@ describe("endpoint path normalization", () => { expect(isRawPassthroughEndpointPath(pathname)).toBe(true); }); - test.each(["/v1/messages", "/v1/responses", "/v1/messages/count", "/v1/responses/mini"])( - "non-target path is not misclassified for %s", - (pathname) => { - expect(isCountTokensEndpointPath(pathname)).toBe(false); - expect(isResponseCompactEndpointPath(pathname)).toBe(false); - expect(isRawPassthroughEndpointPath(pathname)).toBe(false); - expect(isCountTokensRequestWithEndpoint(pathname)).toBe(false); - } - ); + test.each([ + "/v1/messages", + "/v1/responses", + "/v1/messages/count", + "/v1/responses/mini", + ])("non-target path is not misclassified for %s", (pathname) => { + expect(isCountTokensEndpointPath(pathname)).toBe(false); + expect(isResponseCompactEndpointPath(pathname)).toBe(false); + expect(isRawPassthroughEndpointPath(pathname)).toBe(false); + expect(isCountTokensRequestWithEndpoint(pathname)).toBe(false); + }); test("session count_tokens detection handles null endpoint", () => { expect(isCountTokensRequestWithEndpoint(null)).toBe(false); diff --git a/tests/unit/proxy/error-handler-terminal-status.test.ts b/tests/unit/proxy/error-handler-terminal-status.test.ts index 98b2d5a60..ed5350590 100644 --- a/tests/unit/proxy/error-handler-terminal-status.test.ts +++ b/tests/unit/proxy/error-handler-terminal-status.test.ts @@ -159,43 +159,36 @@ describe("ProxyErrorHandler.handle terminal status", () => { ); }); - test.each(RATE_LIMIT_CASES)( - "maps $limitType limits to HTTP $expectedStatus", - async ({ limitType, expectedStatus }) => { - const session = await createSession(); - const error = new RateLimitError( - "rate_limit_error", - "limit exceeded", - limitType, - 12, - 20, - null - ); - - const response = await ProxyErrorHandler.handle(session, error); - - expect(response.status).toBe(expectedStatus); - expect(await response.json()).toEqual({ - error: { - type: "rate_limit_error", - message: "limit exceeded", - code: "rate_limit_exceeded", - limit_type: limitType, - current: 12, - limit: 20, - reset_time: null, - }, - }); - expect(mocks.emitProxyLangfuseTrace).toHaveBeenCalledWith( - session, - expect.objectContaining({ - responseText: "", - statusCode: expectedStatus, - errorMessage: "limit exceeded", - }) - ); - } - ); + test.each(RATE_LIMIT_CASES)("maps $limitType limits to HTTP $expectedStatus", async ({ + limitType, + expectedStatus, + }) => { + const session = await createSession(); + const error = new RateLimitError("rate_limit_error", "limit exceeded", limitType, 12, 20, null); + + const response = await ProxyErrorHandler.handle(session, error); + + expect(response.status).toBe(expectedStatus); + expect(await response.json()).toEqual({ + error: { + type: "rate_limit_error", + message: "limit exceeded", + code: "rate_limit_exceeded", + limit_type: limitType, + current: 12, + limit: 20, + reset_time: null, + }, + }); + expect(mocks.emitProxyLangfuseTrace).toHaveBeenCalledWith( + session, + expect.objectContaining({ + responseText: "", + statusCode: expectedStatus, + errorMessage: "limit exceeded", + }) + ); + }); test("keeps fixed-window rate-limit headers", async () => { const session = await createSession(); diff --git a/tests/unit/proxy/fake-streaming-response-validator.test.ts b/tests/unit/proxy/fake-streaming-response-validator.test.ts index 310f45b0c..83b33deba 100644 --- a/tests/unit/proxy/fake-streaming-response-validator.test.ts +++ b/tests/unit/proxy/fake-streaming-response-validator.test.ts @@ -15,54 +15,64 @@ function failure(family: ProtocolFamily, body: string, isStream: boolean, status describe("validateUpstreamResponse", () => { describe("status code handling", () => { - test.each(["anthropic", "openai-chat", "openai-responses", "gemini"])( - "%s: non-2xx is failure regardless of body", - (family) => { - const valid = `{"id":"ok","model":"m","content":[{"type":"text","text":"hi"}]}`; - expect(failure(family, valid, false, 500).ok).toBe(false); - expect(failure(family, valid, false, 502).ok).toBe(false); - expect(failure(family, valid, false, 429).ok).toBe(false); - expect(failure(family, valid, false, 401).ok).toBe(false); - } - ); + test.each([ + "anthropic", + "openai-chat", + "openai-responses", + "gemini", + ])("%s: non-2xx is failure regardless of body", (family) => { + const valid = `{"id":"ok","model":"m","content":[{"type":"text","text":"hi"}]}`; + expect(failure(family, valid, false, 500).ok).toBe(false); + expect(failure(family, valid, false, 502).ok).toBe(false); + expect(failure(family, valid, false, 429).ok).toBe(false); + expect(failure(family, valid, false, 401).ok).toBe(false); + }); }); describe("empty / whitespace bodies", () => { - test.each(["anthropic", "openai-chat", "openai-responses", "gemini"])( - "%s: empty body fails (non-stream)", - (family) => { - expect(failure(family, "", false).ok).toBe(false); - expect(failure(family, " ", false).ok).toBe(false); - expect(failure(family, "\n\n \t\n", false).ok).toBe(false); - } - ); + test.each([ + "anthropic", + "openai-chat", + "openai-responses", + "gemini", + ])("%s: empty body fails (non-stream)", (family) => { + expect(failure(family, "", false).ok).toBe(false); + expect(failure(family, " ", false).ok).toBe(false); + expect(failure(family, "\n\n \t\n", false).ok).toBe(false); + }); - test.each(["anthropic", "openai-chat", "openai-responses", "gemini"])( - "%s: empty body fails (stream)", - (family) => { - expect(failure(family, "", true).ok).toBe(false); - expect(failure(family, " ", true).ok).toBe(false); - } - ); + test.each([ + "anthropic", + "openai-chat", + "openai-responses", + "gemini", + ])("%s: empty body fails (stream)", (family) => { + expect(failure(family, "", true).ok).toBe(false); + expect(failure(family, " ", true).ok).toBe(false); + }); }); describe("invalid JSON for non-stream", () => { - test.each(["anthropic", "openai-chat", "openai-responses", "gemini"])( - "%s: invalid JSON fails non-stream", - (family) => { - expect(failure(family, "not-json", false).ok).toBe(false); - expect(failure(family, "{ truncated", false).ok).toBe(false); - } - ); + test.each([ + "anthropic", + "openai-chat", + "openai-responses", + "gemini", + ])("%s: invalid JSON fails non-stream", (family) => { + expect(failure(family, "not-json", false).ok).toBe(false); + expect(failure(family, "{ truncated", false).ok).toBe(false); + }); }); describe("SSE failure cases", () => { - test.each(["anthropic", "openai-chat", "openai-responses", "gemini"])( - "%s: comment-only SSE fails", - (family) => { - expect(failure(family, ": ping\n\n: ping\n\n", true).ok).toBe(false); - } - ); + test.each([ + "anthropic", + "openai-chat", + "openai-responses", + "gemini", + ])("%s: comment-only SSE fails", (family) => { + expect(failure(family, ": ping\n\n: ping\n\n", true).ok).toBe(false); + }); test("openai-chat: [DONE]-only SSE fails", () => { expect(failure("openai-chat", "data: [DONE]\n\n", true).ok).toBe(false); diff --git a/tests/unit/proxy/fake-streaming-response.test.ts b/tests/unit/proxy/fake-streaming-response.test.ts index bc6350f76..88cad4aa6 100644 --- a/tests/unit/proxy/fake-streaming-response.test.ts +++ b/tests/unit/proxy/fake-streaming-response.test.ts @@ -40,13 +40,15 @@ function parseSseEvents(body: string): Array<{ event: string | null; data: strin } describe("emitFinalNonStream", () => { - test.each(["anthropic", "openai-chat", "openai-responses", "gemini"])( - "%s: returns the validated final body verbatim", - (family) => { - const body = JSON.stringify({ id: "x", model: "m", content: [{ type: "text", text: "hi" }] }); - expect(emitFinalNonStream({ family, finalBody: body })).toBe(body); - } - ); + test.each([ + "anthropic", + "openai-chat", + "openai-responses", + "gemini", + ])("%s: returns the validated final body verbatim", (family) => { + const body = JSON.stringify({ id: "x", model: "m", content: [{ type: "text", text: "hi" }] }); + expect(emitFinalNonStream({ family, finalBody: body })).toBe(body); + }); }); describe("emitFinalStream — anthropic", () => { diff --git a/tests/unit/proxy/fake-streaming-stream-intent.test.ts b/tests/unit/proxy/fake-streaming-stream-intent.test.ts index f4b623048..75f65067e 100644 --- a/tests/unit/proxy/fake-streaming-stream-intent.test.ts +++ b/tests/unit/proxy/fake-streaming-stream-intent.test.ts @@ -26,31 +26,33 @@ function inputs({ describe("detectClientStreamIntent", () => { describe("standard formats (claude / openai / response)", () => { - test.each(["claude", "openai", "response"])( - "%s: body.stream === true => stream", - (format) => { - expect( - detectClientStreamIntent( - inputs({ format, pathname: "/v1/messages", body: { stream: true } }) - ) - ).toBe(true); - } - ); + test.each([ + "claude", + "openai", + "response", + ])("%s: body.stream === true => stream", (format) => { + expect( + detectClientStreamIntent( + inputs({ format, pathname: "/v1/messages", body: { stream: true } }) + ) + ).toBe(true); + }); - test.each(["claude", "openai", "response"])( - "%s: body.stream missing or false => non-stream", - (format) => { - expect( - detectClientStreamIntent( - inputs({ format, pathname: "/v1/messages", body: { stream: false } }) - ) - ).toBe(false); - expect( - detectClientStreamIntent(inputs({ format, pathname: "/v1/messages", body: {} })) - ).toBe(false); - expect(detectClientStreamIntent(inputs({ format, pathname: "/v1/messages" }))).toBe(false); - } - ); + test.each([ + "claude", + "openai", + "response", + ])("%s: body.stream missing or false => non-stream", (format) => { + expect( + detectClientStreamIntent( + inputs({ format, pathname: "/v1/messages", body: { stream: false } }) + ) + ).toBe(false); + expect(detectClientStreamIntent(inputs({ format, pathname: "/v1/messages", body: {} }))).toBe( + false + ); + expect(detectClientStreamIntent(inputs({ format, pathname: "/v1/messages" }))).toBe(false); + }); test("standard formats ignore path / query for stream intent", () => { expect( @@ -67,20 +69,20 @@ describe("detectClientStreamIntent", () => { }); describe("gemini family", () => { - test.each(["gemini", "gemini-cli"])( - "%s: streamGenerateContent in path => stream", - (format) => { - expect( - detectClientStreamIntent( - inputs({ - format, - pathname: "/v1beta/models/gemini-1.5-pro:streamGenerateContent", - body: {}, - }) - ) - ).toBe(true); - } - ); + test.each([ + "gemini", + "gemini-cli", + ])("%s: streamGenerateContent in path => stream", (format) => { + expect( + detectClientStreamIntent( + inputs({ + format, + pathname: "/v1beta/models/gemini-1.5-pro:streamGenerateContent", + body: {}, + }) + ) + ).toBe(true); + }); test.each(["gemini", "gemini-cli"])("%s: alt=sse query => stream", (format) => { expect( @@ -95,43 +97,43 @@ describe("detectClientStreamIntent", () => { ).toBe(true); }); - test.each(["gemini", "gemini-cli"])( - "%s: body.stream === true => stream", - (format) => { - expect( - detectClientStreamIntent( - inputs({ - format, - pathname: "/v1beta/models/gemini-1.5-pro:generateContent", - body: { stream: true }, - }) - ) - ).toBe(true); - } - ); + test.each([ + "gemini", + "gemini-cli", + ])("%s: body.stream === true => stream", (format) => { + expect( + detectClientStreamIntent( + inputs({ + format, + pathname: "/v1beta/models/gemini-1.5-pro:generateContent", + body: { stream: true }, + }) + ) + ).toBe(true); + }); - test.each(["gemini", "gemini-cli"])( - "%s: no streaming signal => non-stream", - (format) => { - expect( - detectClientStreamIntent( - inputs({ - format, - pathname: "/v1beta/models/gemini-1.5-pro:generateContent", - body: { stream: false }, - }) - ) - ).toBe(false); - expect( - detectClientStreamIntent( - inputs({ - format, - pathname: "/v1beta/models/gemini-1.5-pro:generateContent", - }) - ) - ).toBe(false); - } - ); + test.each([ + "gemini", + "gemini-cli", + ])("%s: no streaming signal => non-stream", (format) => { + expect( + detectClientStreamIntent( + inputs({ + format, + pathname: "/v1beta/models/gemini-1.5-pro:generateContent", + body: { stream: false }, + }) + ) + ).toBe(false); + expect( + detectClientStreamIntent( + inputs({ + format, + pathname: "/v1beta/models/gemini-1.5-pro:generateContent", + }) + ) + ).toBe(false); + }); test("gemini search supports object form", () => { expect( diff --git a/tests/unit/proxy/provider-selector-cross-type-model.test.ts b/tests/unit/proxy/provider-selector-cross-type-model.test.ts index 94eaec709..e883463dc 100644 --- a/tests/unit/proxy/provider-selector-cross-type-model.test.ts +++ b/tests/unit/proxy/provider-selector-cross-type-model.test.ts @@ -198,18 +198,21 @@ describe("providerSupportsModel - direct unit tests (#832)", () => { }, ]; - test.each(cases)( - "$name", - async ({ providerType, allowedModels, modelRedirects, requestedModel, expected }) => { - const { providerSupportsModel } = await import("@/app/v1/_lib/proxy/provider-selector"); - const provider = createProvider({ - providerType, - allowedModels, - ...(modelRedirects && { modelRedirects }), - }); - expect(providerSupportsModel(provider, requestedModel)).toBe(expected); - } - ); + test.each(cases)("$name", async ({ + providerType, + allowedModels, + modelRedirects, + requestedModel, + expected, + }) => { + const { providerSupportsModel } = await import("@/app/v1/_lib/proxy/provider-selector"); + const provider = createProvider({ + providerType, + allowedModels, + ...(modelRedirects && { modelRedirects }), + }); + expect(providerSupportsModel(provider, requestedModel)).toBe(expected); + }); }); // ══════════════════════════════════════════════════════════════════ diff --git a/tests/unit/proxy/proxy-forwarder-endpoint-audit.test.ts b/tests/unit/proxy/proxy-forwarder-endpoint-audit.test.ts index a02dc05a2..51610de69 100644 --- a/tests/unit/proxy/proxy-forwarder-endpoint-audit.test.ts +++ b/tests/unit/proxy/proxy-forwarder-endpoint-audit.test.ts @@ -418,69 +418,68 @@ describe("ProxyForwarder - endpoint audit", () => { test.each([ { requestPath: "/v1/messages/count_tokens", providerType: "claude" as const }, { requestPath: "/v1/responses/compact", providerType: "codex" as const }, - ])( - "raw 端点 $requestPath: endpoint 选择失败时不应静默回退到 provider.url", - async ({ requestPath, providerType }) => { - const session = createSession(new URL(`https://example.com${requestPath}`)); - const provider = createProvider({ - providerType, - providerVendorId: 123, - url: `https://provider.example.com${requestPath}?key=SECRET`, - }); - session.setProvider(provider); + ])("raw 端点 $requestPath: endpoint 选择失败时不应静默回退到 provider.url", async ({ + requestPath, + providerType, + }) => { + const session = createSession(new URL(`https://example.com${requestPath}`)); + const provider = createProvider({ + providerType, + providerVendorId: 123, + url: `https://provider.example.com${requestPath}?key=SECRET`, + }); + session.setProvider(provider); - mocks.getPreferredProviderEndpoints.mockRejectedValueOnce(new Error("boom")); + mocks.getPreferredProviderEndpoints.mockRejectedValueOnce(new Error("boom")); - const doForward = vi.spyOn( - ProxyForwarder as unknown as { doForward: (...args: unknown[]) => unknown }, - "doForward" - ); - doForward.mockResolvedValueOnce( - new Response("{}", { - status: 200, - headers: { - "content-type": "application/json", - "content-length": "2", - }, - }) - ); + const doForward = vi.spyOn( + ProxyForwarder as unknown as { doForward: (...args: unknown[]) => unknown }, + "doForward" + ); + doForward.mockResolvedValueOnce( + new Response("{}", { + status: 200, + headers: { + "content-type": "application/json", + "content-length": "2", + }, + }) + ); - const rejected = await ProxyForwarder.send(session) - .then(() => false) - .catch(() => true); + const rejected = await ProxyForwarder.send(session) + .then(() => false) + .catch(() => true); - expect( - rejected, - `raw 端点 ${requestPath} endpoint 选择失败后不允许静默回退 provider.url` - ).toBe(true); - expect(doForward).not.toHaveBeenCalled(); + expect(rejected, `raw 端点 ${requestPath} endpoint 选择失败后不允许静默回退 provider.url`).toBe( + true + ); + expect(doForward).not.toHaveBeenCalled(); - expect(logger.warn).toHaveBeenCalledWith( - "[ProxyForwarder] Failed to load provider endpoints", - expect.objectContaining({ - providerId: provider.id, - vendorId: 123, - providerType, - strictEndpointPolicy: true, - reason: "selector_error", - error: "boom", - }) - ); + expect(logger.warn).toHaveBeenCalledWith( + "[ProxyForwarder] Failed to load provider endpoints", + expect.objectContaining({ + providerId: provider.id, + vendorId: 123, + providerType, + strictEndpointPolicy: true, + reason: "selector_error", + error: "boom", + }) + ); - expect(logger.warn).toHaveBeenCalledWith( - "ProxyForwarder: Strict endpoint policy blocked legacy provider.url fallback", - expect.objectContaining({ - providerId: provider.id, - vendorId: 123, - providerType, - requestPath, - reason: "strict_blocked_legacy_fallback", - strictBlockCause: "selector_error", - selectorError: "boom", - }) - ); - } - ); + expect(logger.warn).toHaveBeenCalledWith( + "ProxyForwarder: Strict endpoint policy blocked legacy provider.url fallback", + expect.objectContaining({ + providerId: provider.id, + vendorId: 123, + providerType, + requestPath, + reason: "strict_blocked_legacy_fallback", + strictBlockCause: "selector_error", + selectorError: "boom", + }) + ); + }); test("raw 端点空候选应记录 no_endpoint_candidates 且不混淆为 selector_error", async () => { const requestPath = "/v1/messages/count_tokens"; diff --git a/tests/unit/proxy/proxy-forwarder-hedge-first-byte.test.ts b/tests/unit/proxy/proxy-forwarder-hedge-first-byte.test.ts index 3bc0b6c23..dd95e53fc 100644 --- a/tests/unit/proxy/proxy-forwarder-hedge-first-byte.test.ts +++ b/tests/unit/proxy/proxy-forwarder-hedge-first-byte.test.ts @@ -477,52 +477,52 @@ describe("ProxyForwarder - first-byte hedge scheduling", () => { expect(mocks.acquireSessionDiscoveryLease).toHaveBeenCalledTimes(1); }); - test.each(["unknown", "unavailable"] as const)( - "Discovery fails closed when the binding capability probe returns %s", - async (capabilityState) => { - const provider = createProvider({ id: 1 }); - const session = createSession(); - session.authState = { - success: true, - user: null, - key: { id: 21 }, - apiKey: null, - } as typeof session.authState; - session.setProvider(provider); - mocks.ensureVersionedBindingCapability.mockResolvedValueOnce(capabilityState); + test.each([ + "unknown", + "unavailable", + ] as const)("Discovery fails closed when the binding capability probe returns %s", async (capabilityState) => { + const provider = createProvider({ id: 1 }); + const session = createSession(); + session.authState = { + success: true, + user: null, + key: { id: 21 }, + apiKey: null, + } as typeof session.authState; + session.setProvider(provider); + mocks.ensureVersionedBindingCapability.mockResolvedValueOnce(capabilityState); - const prepareStreamingDiscovery = ( - ProxyForwarder as unknown as { - prepareStreamingDiscovery: ( - session: ProxySession, - settings: SystemSettings, - requestStartedAt: number - ) => Promise; - } - ).prepareStreamingDiscovery; - const prepared = await prepareStreamingDiscovery( - session, - { - discoveryEnabled: true, - discoveryConcurrency: 2, - maxDiscoveryRounds: 1, - discoverySlaMs: 50, - stickySlaMs: 50, - racingTotalTimeoutMs: 200, - stickyTimeoutCooldownMs: 300_000, - } as SystemSettings, - Date.now() - ); + const prepareStreamingDiscovery = ( + ProxyForwarder as unknown as { + prepareStreamingDiscovery: ( + session: ProxySession, + settings: SystemSettings, + requestStartedAt: number + ) => Promise; + } + ).prepareStreamingDiscovery; + const prepared = await prepareStreamingDiscovery( + session, + { + discoveryEnabled: true, + discoveryConcurrency: 2, + maxDiscoveryRounds: 1, + discoverySlaMs: 50, + stickySlaMs: 50, + racingTotalTimeoutMs: 200, + stickyTimeoutCooldownMs: 300_000, + } as SystemSettings, + Date.now() + ); - expect(prepared).toEqual({ - status: "skipped", - reason: "redis_capability_unavailable", - }); - expect(mocks.ensureVersionedBindingCapability).toHaveBeenCalledTimes(1); - expect(mocks.getSessionBindingSnapshot).not.toHaveBeenCalled(); - expect(mocks.acquireSessionDiscoveryLease).not.toHaveBeenCalled(); - } - ); + expect(prepared).toEqual({ + status: "skipped", + reason: "redis_capability_unavailable", + }); + expect(mocks.ensureVersionedBindingCapability).toHaveBeenCalledTimes(1); + expect(mocks.getSessionBindingSnapshot).not.toHaveBeenCalled(); + expect(mocks.acquireSessionDiscoveryLease).not.toHaveBeenCalled(); + }); test("shadow session redirect should not overwrite initial provider redirect and winner should keep its own redirect", () => { const requestedModel = "claude-haiku-4-5-20251001"; @@ -1976,86 +1976,82 @@ describe("ProxyForwarder - first-byte hedge scheduling", () => { category: ProxyErrorCategory.SYSTEM_ERROR, errorFactory: () => new Error("fetch failed"), }, - ])( - "when a real hedge race ends with only $name, terminal error should be generic fallback", - async ({ category, errorFactory }) => { - vi.useFakeTimers(); - - try { - const provider1 = createProvider({ - id: 1, - name: "p1", - firstByteTimeoutStreamingMs: 100, - }); - const provider2 = createProvider({ - id: 2, - name: "p2", - firstByteTimeoutStreamingMs: 100, - }); - const session = createSession(); - session.setProvider(provider1); + ])("when a real hedge race ends with only $name, terminal error should be generic fallback", async ({ + category, + errorFactory, + }) => { + vi.useFakeTimers(); + + try { + const provider1 = createProvider({ + id: 1, + name: "p1", + firstByteTimeoutStreamingMs: 100, + }); + const provider2 = createProvider({ + id: 2, + name: "p2", + firstByteTimeoutStreamingMs: 100, + }); + const session = createSession(); + session.setProvider(provider1); - mocks.pickRandomProviderWithExclusion - .mockResolvedValueOnce(provider2) - .mockResolvedValueOnce(null); - mocks.categorizeErrorAsync.mockResolvedValueOnce(category).mockResolvedValueOnce(category); + mocks.pickRandomProviderWithExclusion + .mockResolvedValueOnce(provider2) + .mockResolvedValueOnce(null); + mocks.categorizeErrorAsync.mockResolvedValueOnce(category).mockResolvedValueOnce(category); - const doForward = vi.spyOn( - ProxyForwarder as unknown as { - doForward: (...args: unknown[]) => Promise; - }, - "doForward" - ); + const doForward = vi.spyOn( + ProxyForwarder as unknown as { + doForward: (...args: unknown[]) => Promise; + }, + "doForward" + ); - const controller1 = new AbortController(); - const controller2 = new AbortController(); - - doForward.mockImplementationOnce(async (attemptSession) => { - const runtime = attemptSession as ProxySession & AttemptRuntime; - runtime.responseController = controller1; - runtime.clearResponseTimeout = vi.fn(); - return createDelayedFailure({ - delayMs: 150, - error: errorFactory(provider1), - controller: controller1, - }); + const controller1 = new AbortController(); + const controller2 = new AbortController(); + + doForward.mockImplementationOnce(async (attemptSession) => { + const runtime = attemptSession as ProxySession & AttemptRuntime; + runtime.responseController = controller1; + runtime.clearResponseTimeout = vi.fn(); + return createDelayedFailure({ + delayMs: 150, + error: errorFactory(provider1), + controller: controller1, }); + }); - doForward.mockImplementationOnce(async (attemptSession) => { - const runtime = attemptSession as ProxySession & AttemptRuntime; - runtime.responseController = controller2; - runtime.clearResponseTimeout = vi.fn(); - return createDelayedFailure({ - delayMs: 160, - error: errorFactory(provider2), - controller: controller2, - }); + doForward.mockImplementationOnce(async (attemptSession) => { + const runtime = attemptSession as ProxySession & AttemptRuntime; + runtime.responseController = controller2; + runtime.clearResponseTimeout = vi.fn(); + return createDelayedFailure({ + delayMs: 160, + error: errorFactory(provider2), + controller: controller2, }); + }); - const responsePromise = ProxyForwarder.send(session); - const errorPromise = responsePromise.catch((rejection) => rejection as UpstreamProxyError); + const responsePromise = ProxyForwarder.send(session); + const errorPromise = responsePromise.catch((rejection) => rejection as UpstreamProxyError); - await vi.advanceTimersByTimeAsync(100); - expect(doForward).toHaveBeenCalledTimes(2); + await vi.advanceTimersByTimeAsync(100); + expect(doForward).toHaveBeenCalledTimes(2); - await vi.runAllTimersAsync(); - const error = await errorPromise; + await vi.runAllTimersAsync(); + const error = await errorPromise; - expect(error).toBeInstanceOf(UpstreamProxyError); - expect(error.statusCode).toBe(503); - expect(error.message).toBe("所有供应商暂时不可用,请稍后重试"); - expect(error.message).not.toContain("invalid key"); - expect(error.message).not.toContain("model not found"); - expect(mocks.clearSessionProviders).toHaveBeenCalledWith( - "sess-hedge", - new Set([1, 2]), - null - ); - } finally { - vi.useRealTimers(); - } + expect(error).toBeInstanceOf(UpstreamProxyError); + expect(error.statusCode).toBe(503); + expect(error.message).toBe("所有供应商暂时不可用,请稍后重试"); + expect(error.message).not.toContain("invalid key"); + expect(error.message).not.toContain("model not found"); + expect(mocks.clearSessionProviders).toHaveBeenCalledWith("sess-hedge", new Set([1, 2]), null); + } finally { + vi.useRealTimers(); } - ); + }); test("non-retryable client errors should stop hedge immediately and preserve original error", async () => { const provider1 = createProvider({ diff --git a/tests/unit/proxy/proxy-forwarder-retry-limit.test.ts b/tests/unit/proxy/proxy-forwarder-retry-limit.test.ts index ff51303ba..e5b8dea79 100644 --- a/tests/unit/proxy/proxy-forwarder-retry-limit.test.ts +++ b/tests/unit/proxy/proxy-forwarder-retry-limit.test.ts @@ -243,64 +243,64 @@ describe("ProxyForwarder - raw passthrough fallback parity", () => { vi.mocked(categorizeErrorAsync).mockResolvedValue(ErrorCategory.PROVIDER_ERROR); }); - test.each([V1_ENDPOINT_PATHS.MESSAGES_COUNT_TOKENS, V1_ENDPOINT_PATHS.RESPONSES_COMPACT])( - "%s 失败时应允许跨 provider fallback,但仍保持 no-circuit", - async (pathname) => { - vi.useFakeTimers(); - - try { - const session = createSession(new URL(`https://example.com${pathname}`)); - const provider = createProvider({ + test.each([ + V1_ENDPOINT_PATHS.MESSAGES_COUNT_TOKENS, + V1_ENDPOINT_PATHS.RESPONSES_COMPACT, + ])("%s 失败时应允许跨 provider fallback,但仍保持 no-circuit", async (pathname) => { + vi.useFakeTimers(); + + try { + const session = createSession(new URL(`https://example.com${pathname}`)); + const provider = createProvider({ + providerType: "claude", + providerVendorId: 123, + maxRetryAttempts: 3, + }); + session.setProvider(provider); + + mocks.getPreferredProviderEndpoints.mockResolvedValue([ + makeEndpoint({ + id: 1, + vendorId: 123, providerType: "claude", - providerVendorId: 123, - maxRetryAttempts: 3, - }); - session.setProvider(provider); - - mocks.getPreferredProviderEndpoints.mockResolvedValue([ - makeEndpoint({ - id: 1, - vendorId: 123, - providerType: "claude", - url: "https://ep1.example.com", - }), - makeEndpoint({ - id: 2, - vendorId: 123, - providerType: "claude", - url: "https://ep2.example.com", - }), - ]); - - const doForward = vi.spyOn( - ProxyForwarder as unknown as { doForward: (...args: unknown[]) => unknown }, - "doForward" - ); - const selectAlternative = vi.spyOn( - ProxyForwarder as unknown as { selectAlternative: (...args: unknown[]) => unknown }, - "selectAlternative" - ); - - doForward.mockImplementation(async () => { - throw new ProxyError("upstream failed", 500); - }); + url: "https://ep1.example.com", + }), + makeEndpoint({ + id: 2, + vendorId: 123, + providerType: "claude", + url: "https://ep2.example.com", + }), + ]); - const sendPromise = ProxyForwarder.send(session); - let caughtError: Error | null = null; - sendPromise.catch((error) => { - caughtError = error as Error; - }); - await vi.runAllTimersAsync(); - - expect(caughtError).toBeInstanceOf(ProxyError); - expect(doForward).toHaveBeenCalledTimes(1); - expect(selectAlternative).toHaveBeenCalledTimes(1); - expect(mocks.recordFailure).not.toHaveBeenCalled(); - } finally { - vi.useRealTimers(); - } + const doForward = vi.spyOn( + ProxyForwarder as unknown as { doForward: (...args: unknown[]) => unknown }, + "doForward" + ); + const selectAlternative = vi.spyOn( + ProxyForwarder as unknown as { selectAlternative: (...args: unknown[]) => unknown }, + "selectAlternative" + ); + + doForward.mockImplementation(async () => { + throw new ProxyError("upstream failed", 500); + }); + + const sendPromise = ProxyForwarder.send(session); + let caughtError: Error | null = null; + sendPromise.catch((error) => { + caughtError = error as Error; + }); + await vi.runAllTimersAsync(); + + expect(caughtError).toBeInstanceOf(ProxyError); + expect(doForward).toHaveBeenCalledTimes(1); + expect(selectAlternative).toHaveBeenCalledTimes(1); + expect(mocks.recordFailure).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); } - ); + }); }); describe("ProxyForwarder - retry limit enforcement", () => { diff --git a/tests/unit/proxy/session.test.ts b/tests/unit/proxy/session.test.ts index 5c77a0a29..306e5ba3e 100644 --- a/tests/unit/proxy/session.test.ts +++ b/tests/unit/proxy/session.test.ts @@ -117,19 +117,19 @@ function createSession({ } describe("ProxySession endpoint policy", () => { - it.each([V1_ENDPOINT_PATHS.MESSAGES_COUNT_TOKENS, "/V1/RESPONSES/COMPACT/"])( - "应在创建时解析 raw passthrough policy: %s", - (pathname) => { - const session = createSession({ - redirectedModel: null, - requestUrl: new URL(`http://localhost${pathname}`), - }); - - const policy = session.getEndpointPolicy(); - expect(isRawPassthroughEndpointPolicy(policy)).toBe(true); - expect(policy.trackConcurrentRequests).toBe(false); - } - ); + it.each([ + V1_ENDPOINT_PATHS.MESSAGES_COUNT_TOKENS, + "/V1/RESPONSES/COMPACT/", + ])("应在创建时解析 raw passthrough policy: %s", (pathname) => { + const session = createSession({ + redirectedModel: null, + requestUrl: new URL(`http://localhost${pathname}`), + }); + + const policy = session.getEndpointPolicy(); + expect(isRawPassthroughEndpointPolicy(policy)).toBe(true); + expect(policy.trackConcurrentRequests).toBe(false); + }); it("应在请求路径后续变更后保持创建时 policy 不变", () => { const session = createSession({ diff --git a/tests/unit/server-response-write-backpressure.test.ts b/tests/unit/server-response-write-backpressure.test.ts index f8c02a456..ce257940e 100644 --- a/tests/unit/server-response-write-backpressure.test.ts +++ b/tests/unit/server-response-write-backpressure.test.ts @@ -137,50 +137,50 @@ describe("server response write backpressure", () => { await forwarding; }); - it.each(["ECONNREFUSED", "ECONNRESET"])( - "sends one fatal frame and waits for its acknowledgement on active request error %s", - async (code) => { - const events: string[] = []; - const request = createClientRequest(false, events); - vi.spyOn(http, "request").mockImplementation(() => request); - const input = requestInput(); - const sent: string[] = []; - let sendCallback: ((error?: Error) => void) | undefined; - input.ws.send = (payload, callback) => { - sent.push(payload); - sendCallback = callback; - }; - const close = vi.fn(); - - const forwarding = serverModule.forwardToInternalHttp( - input.ws, - input.request, - input.body, - "request-error-session", - undefined, - close - ); - let settled = false; - void forwarding.then(() => { - settled = true; - }); - - request.emit("error", Object.assign(new Error(code), { code })); - await new Promise((resolve) => setImmediate(resolve)); - - expect(sent).toHaveLength(1); - expect(JSON.parse(sent[0]).error.code).toBe("internal_request_error"); - expect(settled).toBe(false); - expect(close).not.toHaveBeenCalled(); - - sendCallback?.(); - await forwarding; - expect(close).toHaveBeenCalledWith(1011, "internal_request_error"); - - expect(() => request.emit("error", new Error("late request error"))).not.toThrow(); - expect(sent).toHaveLength(1); - } - ); + it.each([ + "ECONNREFUSED", + "ECONNRESET", + ])("sends one fatal frame and waits for its acknowledgement on active request error %s", async (code) => { + const events: string[] = []; + const request = createClientRequest(false, events); + vi.spyOn(http, "request").mockImplementation(() => request); + const input = requestInput(); + const sent: string[] = []; + let sendCallback: ((error?: Error) => void) | undefined; + input.ws.send = (payload, callback) => { + sent.push(payload); + sendCallback = callback; + }; + const close = vi.fn(); + + const forwarding = serverModule.forwardToInternalHttp( + input.ws, + input.request, + input.body, + "request-error-session", + undefined, + close + ); + let settled = false; + void forwarding.then(() => { + settled = true; + }); + + request.emit("error", Object.assign(new Error(code), { code })); + await new Promise((resolve) => setImmediate(resolve)); + + expect(sent).toHaveLength(1); + expect(JSON.parse(sent[0]).error.code).toBe("internal_request_error"); + expect(settled).toBe(false); + expect(close).not.toHaveBeenCalled(); + + sendCallback?.(); + await forwarding; + expect(close).toHaveBeenCalledWith(1011, "internal_request_error"); + + expect(() => request.emit("error", new Error("late request error"))).not.toThrow(); + expect(sent).toHaveLength(1); + }); it("force-settles an active turn without relying on request destroy events", async () => { const events: string[] = []; From ed012ffafe5c2121ab40f53c4460e6de495fe734 Mon Sep 17 00:00:00 2001 From: ding113 Date: Fri, 24 Jul 2026 21:35:35 -0700 Subject: [PATCH 2/4] feat(proxy): split TTFB and TFFT into separate metrics The stream content gate made the existing ttfb_ms column measure time to first content token (TFFT), not time to first byte (TTFB). Using TFFT as the generation-window start for TPS excluded upstream queuing and neutral frames, systematically inflating throughput numbers. Add first_byte_ms column to message_request and usage_ledger. The proxy session now records true TTFB from the stream gate first-byte callback (committed only for the winning attempt) alongside TFFT from the first chunk handed to the response handler. TPS, output rate, and leaderboard throughput calculations now use firstByteMs as the denominator start. Rows persisted before the gate shipped have NULL first_byte_ms and return null instead of falling back to total duration. Dashboard and status page labels renamed from TTFB to TFFT; the latency breakdown bar gains a three-segment view (TTFB, token wait, generation). --- drizzle/0114_overconfident_ronan.sql | 150 + drizzle/meta/0114_snapshot.json | 5175 +++++++++++++++++ drizzle/meta/_journal.json | 7 + messages/en/dashboard.json | 16 +- messages/en/settings/statusPage.json | 6 +- messages/ja/dashboard.json | 16 +- messages/ja/settings/statusPage.json | 6 +- messages/ru/dashboard.json | 16 +- messages/ru/settings/statusPage.json | 6 +- messages/zh-CN/dashboard.json | 16 +- messages/zh-CN/settings/statusPage.json | 6 +- messages/zh-TW/dashboard.json | 16 +- messages/zh-TW/settings/statusPage.json | 6 +- .../_components/error-details-dialog.test.tsx | 36 +- .../components/LatencyBreakdownBar.tsx | 108 +- .../components/PerformanceTab.tsx | 90 +- .../components/SummaryTab.tsx | 19 +- .../error-details-dialog/index.tsx | 9 +- .../_components/error-details-dialog/types.ts | 56 +- .../_components/usage-logs-table.test.tsx | 23 +- .../logs/_components/usage-logs-table.tsx | 27 +- .../virtualized-logs-table.test.tsx | 23 +- .../_components/virtualized-logs-table.tsx | 31 +- src/app/v1/_lib/proxy/forwarder.ts | 28 +- src/app/v1/_lib/proxy/response-handler.ts | 25 +- src/app/v1/_lib/proxy/session.ts | 48 +- src/app/v1/_lib/proxy/warmup-guard.ts | 3 +- src/drizzle/schema.ts | 10 +- src/lib/langfuse/emit-proxy-trace.ts | 3 +- src/lib/langfuse/trace-proxy-request.ts | 15 +- src/lib/ledger-backfill/service.ts | 4 +- src/lib/ledger-backfill/trigger.sql | 6 +- src/lib/public-status/aggregation-core.ts | 14 +- src/lib/public-status/aggregation.ts | 16 +- src/lib/public-status/rollup-store.ts | 12 +- src/lib/utils/performance-formatter.test.ts | 50 + src/lib/utils/performance-formatter.ts | 14 +- src/repository/leaderboard.ts | 12 +- src/repository/message-write-buffer.ts | 7 +- src/repository/message.ts | 37 +- src/repository/usage-logs.ts | 30 +- src/types/message.ts | 3 +- ...s-virtualized-special-settings-ui.test.tsx | 2 +- tests/unit/dashboard-logs-warmup-ui.test.tsx | 4 +- .../error-details-dialog-warmup-ui.test.tsx | 2 +- tests/unit/langfuse/langfuse-trace.test.ts | 14 +- ...nse-handler-abort-listener-cleanup.test.ts | 5 +- ...esponse-handler-client-abort-drain.test.ts | 249 +- ...handler-endpoint-circuit-isolation.test.ts | 97 +- .../response-handler-lease-decrement.test.ts | 5 +- .../proxy/response-handler-non200.test.ts | 5 +- tests/unit/proxy/session-ttfb-tfft.test.ts | 92 + .../aggregation-core-tps.test.ts | 45 + tests/unit/public-status/aggregation.test.ts | 12 +- tests/unit/public-status/rollup-store.test.ts | 15 +- .../leaderboard-provider-metrics.test.ts | 6 +- .../leaderboard-timezone-parentheses.test.ts | 6 +- .../repository/leaderboard-tps-basis.test.ts | 100 + .../leaderboard-user-model-stats.test.ts | 6 +- .../message-public-readback.test.ts | 4 +- .../message-public-status-rollup.test.ts | 18 +- .../message-session-readback.test.ts | 2 +- ...essage-terminal-public-status-seam.test.ts | 478 +- .../message-terminal-write-apis.test.ts | 2 +- .../message-usage-logs-query.test.ts | 2 +- .../repository/message-write-buffer.test.ts | 139 +- .../usage-logs-actual-response-model.test.ts | 6 +- .../usage-logs-sessionid-filter.test.ts | 4 +- 68 files changed, 6656 insertions(+), 865 deletions(-) create mode 100644 drizzle/0114_overconfident_ronan.sql create mode 100644 drizzle/meta/0114_snapshot.json create mode 100644 src/lib/utils/performance-formatter.test.ts create mode 100644 tests/unit/proxy/session-ttfb-tfft.test.ts create mode 100644 tests/unit/public-status/aggregation-core-tps.test.ts create mode 100644 tests/unit/repository/leaderboard-tps-basis.test.ts diff --git a/drizzle/0114_overconfident_ronan.sql b/drizzle/0114_overconfident_ronan.sql new file mode 100644 index 000000000..28d54ab53 --- /dev/null +++ b/drizzle/0114_overconfident_ronan.sql @@ -0,0 +1,150 @@ +ALTER TABLE "message_request" ADD COLUMN IF NOT EXISTS "first_byte_ms" integer;--> statement-breakpoint +ALTER TABLE "usage_ledger" ADD COLUMN IF NOT EXISTS "first_byte_ms" integer;--> statement-breakpoint + +-- 真 TTFB(first_byte_ms)需要随 message_request 一起投影进 usage_ledger: +-- 重建 fn_upsert_usage_ledger 与触发器列清单(其余内容与 0098/0111 一致)。 +-- 历史行保持 first_byte_ms IS NULL,这正是「无真 TTFB,不计 TPS」的判据,故不做回填。 +CREATE OR REPLACE FUNCTION fn_upsert_usage_ledger() +RETURNS TRIGGER AS $$ +DECLARE + v_final_provider_id integer; + v_is_success boolean; + v_success_rate_outcome varchar; +BEGIN + v_success_rate_outcome := fn_compute_message_request_success_rate_outcome( + NEW.blocked_by, + NEW.status_code, + NEW.error_message, + NEW.provider_chain + ); + + IF NEW.blocked_by = 'warmup' THEN + -- If a ledger row already exists (row was originally non-warmup), mark it as warmup + -- and sync the latest actual_response_model so audit stays consistent across tables. + UPDATE usage_ledger + SET blocked_by = 'warmup', + success_rate_outcome = v_success_rate_outcome, + actual_response_model = NEW.actual_response_model + WHERE request_id = NEW.id; + RETURN NEW; + END IF; + + IF LOWER(REGEXP_REPLACE(COALESCE(NEW.endpoint, ''), '/+$', '')) + IN ('/v1/messages/count_tokens', '/v1/responses/compact') THEN + DELETE FROM usage_ledger WHERE request_id = NEW.id; + RETURN NEW; + END IF; + + IF NEW.provider_chain IS NOT NULL + AND jsonb_typeof(NEW.provider_chain) = 'array' + AND jsonb_array_length(NEW.provider_chain) > 0 + AND jsonb_typeof(NEW.provider_chain -> -1) = 'object' + AND (NEW.provider_chain -> -1 ? 'id') + AND (NEW.provider_chain -> -1 ->> 'id') ~ '^[0-9]+$' THEN + v_final_provider_id := (NEW.provider_chain -> -1 ->> 'id')::integer; + ELSE + v_final_provider_id := NEW.provider_id; + END IF; + + v_is_success := (NEW.error_message IS NULL OR NEW.error_message = '') + AND (NEW.status_code IS NULL OR NEW.status_code < 400); + + INSERT INTO usage_ledger ( + request_id, user_id, key, provider_id, final_provider_id, + model, original_model, actual_response_model, endpoint, api_type, session_id, + status_code, is_success, success_rate_outcome, blocked_by, + cost_usd, cost_multiplier, group_cost_multiplier, + input_tokens, output_tokens, + cache_creation_input_tokens, cache_read_input_tokens, + cache_creation_5m_input_tokens, cache_creation_1h_input_tokens, + cache_ttl_applied, context_1m_applied, swap_cache_ttl_applied, + duration_ms, ttfb_ms, first_byte_ms, client_ip, created_at + ) VALUES ( + NEW.id, NEW.user_id, NEW.key, NEW.provider_id, v_final_provider_id, + NEW.model, NEW.original_model, NEW.actual_response_model, NEW.endpoint, NEW.api_type, NEW.session_id, + NEW.status_code, v_is_success, v_success_rate_outcome, NEW.blocked_by, + NEW.cost_usd, NEW.cost_multiplier, NEW.group_cost_multiplier, + NEW.input_tokens, NEW.output_tokens, + NEW.cache_creation_input_tokens, NEW.cache_read_input_tokens, + NEW.cache_creation_5m_input_tokens, NEW.cache_creation_1h_input_tokens, + NEW.cache_ttl_applied, NEW.context_1m_applied, NEW.swap_cache_ttl_applied, + NEW.duration_ms, NEW.ttfb_ms, NEW.first_byte_ms, NEW.client_ip, NEW.created_at + ) + ON CONFLICT (request_id) DO UPDATE SET + user_id = EXCLUDED.user_id, + key = EXCLUDED.key, + provider_id = EXCLUDED.provider_id, + final_provider_id = EXCLUDED.final_provider_id, + model = EXCLUDED.model, + original_model = EXCLUDED.original_model, + actual_response_model = EXCLUDED.actual_response_model, + endpoint = EXCLUDED.endpoint, + api_type = EXCLUDED.api_type, + session_id = EXCLUDED.session_id, + status_code = EXCLUDED.status_code, + is_success = EXCLUDED.is_success, + success_rate_outcome = EXCLUDED.success_rate_outcome, + blocked_by = EXCLUDED.blocked_by, + cost_usd = EXCLUDED.cost_usd, + cost_multiplier = EXCLUDED.cost_multiplier, + group_cost_multiplier = EXCLUDED.group_cost_multiplier, + input_tokens = EXCLUDED.input_tokens, + output_tokens = EXCLUDED.output_tokens, + cache_creation_input_tokens = EXCLUDED.cache_creation_input_tokens, + cache_read_input_tokens = EXCLUDED.cache_read_input_tokens, + cache_creation_5m_input_tokens = EXCLUDED.cache_creation_5m_input_tokens, + cache_creation_1h_input_tokens = EXCLUDED.cache_creation_1h_input_tokens, + cache_ttl_applied = EXCLUDED.cache_ttl_applied, + context_1m_applied = EXCLUDED.context_1m_applied, + swap_cache_ttl_applied = EXCLUDED.swap_cache_ttl_applied, + duration_ms = EXCLUDED.duration_ms, + ttfb_ms = EXCLUDED.ttfb_ms, + first_byte_ms = EXCLUDED.first_byte_ms, + client_ip = EXCLUDED.client_ip; + -- created_at deliberately NOT updated on conflict: it represents the + -- original insert time of the ledger row, which is immutable by design. + + RETURN NEW; +EXCEPTION WHEN OTHERS THEN + RAISE WARNING 'fn_upsert_usage_ledger failed for request_id=%: %', NEW.id, SQLERRM; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS trg_upsert_usage_ledger ON message_request; + +CREATE TRIGGER trg_upsert_usage_ledger +AFTER INSERT OR UPDATE OF + blocked_by, + status_code, + error_message, + provider_chain, + actual_response_model, + endpoint, + provider_id, + user_id, + "key", + model, + original_model, + api_type, + session_id, + cost_usd, + cost_multiplier, + group_cost_multiplier, + input_tokens, + output_tokens, + cache_creation_input_tokens, + cache_read_input_tokens, + cache_creation_5m_input_tokens, + cache_creation_1h_input_tokens, + cache_ttl_applied, + context_1m_applied, + swap_cache_ttl_applied, + duration_ms, + ttfb_ms, + first_byte_ms, + client_ip, + created_at +ON message_request +FOR EACH ROW +EXECUTE FUNCTION fn_upsert_usage_ledger(); diff --git a/drizzle/meta/0114_snapshot.json b/drizzle/meta/0114_snapshot.json new file mode 100644 index 000000000..cc95d2cd2 --- /dev/null +++ b/drizzle/meta/0114_snapshot.json @@ -0,0 +1,5175 @@ +{ + "id": "63b9d353-72a5-432c-88ff-77e871ddfb68", + "prevId": "87e53bfd-94b5-42d6-b589-0ec54a331157", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.audit_log": { + "name": "audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "action_category": { + "name": "action_category", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "action_type": { + "name": "action_type", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "varchar(32)", + "primaryKey": false, + "notNull": false + }, + "target_id": { + "name": "target_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "target_name": { + "name": "target_name", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "before_value": { + "name": "before_value", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "after_value": { + "name": "after_value", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "operator_user_id": { + "name": "operator_user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "operator_user_name": { + "name": "operator_user_name", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "operator_key_id": { + "name": "operator_key_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "operator_key_name": { + "name": "operator_key_name", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "operator_ip": { + "name": "operator_ip", + "type": "varchar(45)", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "success": { + "name": "success", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_audit_log_category_created_at": { + "name": "idx_audit_log_category_created_at", + "columns": [ + { + "expression": "action_category", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_audit_log_operator_user_created_at": { + "name": "idx_audit_log_operator_user_created_at", + "columns": [ + { + "expression": "operator_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"audit_log\".\"operator_user_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_audit_log_operator_ip_created_at": { + "name": "idx_audit_log_operator_ip_created_at", + "columns": [ + { + "expression": "operator_ip", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"audit_log\".\"operator_ip\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_audit_log_target": { + "name": "idx_audit_log_target", + "columns": [ + { + "expression": "target_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"audit_log\".\"target_type\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_audit_log_created_at_id": { + "name": "idx_audit_log_created_at_id", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.cloud_pricing_catalog": { + "name": "cloud_pricing_catalog", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "version": { + "name": "version", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "default": "'USD'" + }, + "refreshed_at": { + "name": "refreshed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "providers": { + "name": "providers", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "vendors": { + "name": "vendors", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "model_count": { + "name": "model_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "synced_at": { + "name": "synced_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.error_rules": { + "name": "error_rules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "pattern": { + "name": "pattern", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "match_type": { + "name": "match_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'regex'" + }, + "category": { + "name": "category", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "override_response": { + "name": "override_response", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "override_status_code": { + "name": "override_status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_error_rules_enabled": { + "name": "idx_error_rules_enabled", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "unique_pattern": { + "name": "unique_pattern", + "columns": [ + { + "expression": "pattern", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_category": { + "name": "idx_category", + "columns": [ + { + "expression": "category", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_match_type": { + "name": "idx_match_type", + "columns": [ + { + "expression": "match_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.keys": { + "name": "keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "can_login_web_ui": { + "name": "can_login_web_ui", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "limit_5h_usd": { + "name": "limit_5h_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_5h_reset_mode": { + "name": "limit_5h_reset_mode", + "type": "daily_reset_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'rolling'" + }, + "limit_daily_usd": { + "name": "limit_daily_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "daily_reset_mode": { + "name": "daily_reset_mode", + "type": "daily_reset_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'fixed'" + }, + "daily_reset_time": { + "name": "daily_reset_time", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true, + "default": "'00:00'" + }, + "limit_weekly_usd": { + "name": "limit_weekly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_monthly_usd": { + "name": "limit_monthly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_total_usd": { + "name": "limit_total_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "cost_reset_at": { + "name": "cost_reset_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "limit_concurrent_sessions": { + "name": "limit_concurrent_sessions", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "provider_group": { + "name": "provider_group", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false, + "default": "'default'" + }, + "cache_ttl_preference": { + "name": "cache_ttl_preference", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_keys_user_id": { + "name": "idx_keys_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_keys_key": { + "name": "idx_keys_key", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_keys_created_at": { + "name": "idx_keys_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_keys_deleted_at": { + "name": "idx_keys_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.message_request": { + "name": "message_request", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cost_usd": { + "name": "cost_usd", + "type": "numeric(21, 15)", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "cost_multiplier": { + "name": "cost_multiplier", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": false + }, + "group_cost_multiplier": { + "name": "group_cost_multiplier", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": false + }, + "cost_breakdown": { + "name": "cost_breakdown", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "session_id": { + "name": "session_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "request_sequence": { + "name": "request_sequence", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 1 + }, + "provider_chain": { + "name": "provider_chain", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "routing_trace": { + "name": "routing_trace", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "api_type": { + "name": "api_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "endpoint": { + "name": "endpoint", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "original_model": { + "name": "original_model", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "actual_response_model": { + "name": "actual_response_model", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "input_tokens": { + "name": "input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "output_tokens": { + "name": "output_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "ttfb_ms": { + "name": "ttfb_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "first_byte_ms": { + "name": "first_byte_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cache_creation_input_tokens": { + "name": "cache_creation_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_read_input_tokens": { + "name": "cache_read_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_creation_5m_input_tokens": { + "name": "cache_creation_5m_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_creation_1h_input_tokens": { + "name": "cache_creation_1h_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_ttl_applied": { + "name": "cache_ttl_applied", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "context_1m_applied": { + "name": "context_1m_applied", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "swap_cache_ttl_applied": { + "name": "swap_cache_ttl_applied", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "special_settings": { + "name": "special_settings", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "hedge_losers": { + "name": "hedge_losers", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_stack": { + "name": "error_stack", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_cause": { + "name": "error_cause", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "blocked_by": { + "name": "blocked_by", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "blocked_reason": { + "name": "blocked_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "client_ip": { + "name": "client_ip", + "type": "varchar(45)", + "primaryKey": false, + "notNull": false + }, + "messages_count": { + "name": "messages_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cache_compatibility_key": { + "name": "cache_compatibility_key", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "cache_score_eligible": { + "name": "cache_score_eligible", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "cache_score_excluded_reason": { + "name": "cache_score_excluded_reason", + "type": "varchar(32)", + "primaryKey": false, + "notNull": false + }, + "theoretical_cache_tokens": { + "name": "theoretical_cache_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_ttl_bucket": { + "name": "cache_ttl_bucket", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_message_request_user_date_cost": { + "name": "idx_message_request_user_date_cost", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_usd", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_user_created_at_cost_stats": { + "name": "idx_message_request_user_created_at_cost_stats", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_usd", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND (\"message_request\".\"blocked_by\" IS NULL OR \"message_request\".\"blocked_by\" <> 'warmup')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_user_query": { + "name": "idx_message_request_user_query", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_provider_created_at_active": { + "name": "idx_message_request_provider_created_at_active", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND (\"message_request\".\"blocked_by\" IS NULL OR \"message_request\".\"blocked_by\" <> 'warmup')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_provider_created_at_finalized_active": { + "name": "idx_message_request_provider_created_at_finalized_active", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND \"message_request\".\"status_code\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_session_id": { + "name": "idx_message_request_session_id", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_session_id_prefix": { + "name": "idx_message_request_session_id_prefix", + "columns": [ + { + "expression": "\"session_id\" varchar_pattern_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND (\"message_request\".\"blocked_by\" IS NULL OR \"message_request\".\"blocked_by\" <> 'warmup')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_session_seq": { + "name": "idx_message_request_session_seq", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "request_sequence", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_endpoint": { + "name": "idx_message_request_endpoint", + "columns": [ + { + "expression": "endpoint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_blocked_by": { + "name": "idx_message_request_blocked_by", + "columns": [ + { + "expression": "blocked_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_provider_id": { + "name": "idx_message_request_provider_id", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_user_id": { + "name": "idx_message_request_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_key": { + "name": "idx_message_request_key", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_key_created_at_id": { + "name": "idx_message_request_key_created_at_id", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_key_model_active": { + "name": "idx_message_request_key_model_active", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND \"message_request\".\"model\" IS NOT NULL AND (\"message_request\".\"blocked_by\" IS NULL OR \"message_request\".\"blocked_by\" <> 'warmup')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_key_endpoint_active": { + "name": "idx_message_request_key_endpoint_active", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "endpoint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND \"message_request\".\"endpoint\" IS NOT NULL AND (\"message_request\".\"blocked_by\" IS NULL OR \"message_request\".\"blocked_by\" <> 'warmup')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_created_at_id_active": { + "name": "idx_message_request_created_at_id_active", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_model_active": { + "name": "idx_message_request_model_active", + "columns": [ + { + "expression": "model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND \"message_request\".\"model\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_status_code_active": { + "name": "idx_message_request_status_code_active", + "columns": [ + { + "expression": "status_code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND \"message_request\".\"status_code\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_created_at": { + "name": "idx_message_request_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_deleted_at": { + "name": "idx_message_request_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_key_last_active": { + "name": "idx_message_request_key_last_active", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND (\"message_request\".\"blocked_by\" IS NULL OR \"message_request\".\"blocked_by\" <> 'warmup')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_key_cost_active": { + "name": "idx_message_request_key_cost_active", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_usd", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND (\"message_request\".\"blocked_by\" IS NULL OR \"message_request\".\"blocked_by\" <> 'warmup')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_session_user_info": { + "name": "idx_message_request_session_user_info", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_client_ip_created_at": { + "name": "idx_message_request_client_ip_created_at", + "columns": [ + { + "expression": "client_ip", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND \"message_request\".\"client_ip\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.model_prices": { + "name": "model_prices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "model_name": { + "name": "model_name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "price_data": { + "name": "price_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'cloud'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_model_prices_latest": { + "name": "idx_model_prices_latest", + "columns": [ + { + "expression": "model_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_model_prices_model_name": { + "name": "idx_model_prices_model_name", + "columns": [ + { + "expression": "model_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_model_prices_created_at": { + "name": "idx_model_prices_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_model_prices_source": { + "name": "idx_model_prices_source", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_model_prices_vendor": { + "name": "idx_model_prices_vendor", + "columns": [ + { + "expression": "((\"price_data\" ->> 'vendor'))", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_model_prices_aliases": { + "name": "idx_model_prices_aliases", + "columns": [ + { + "expression": "((\"price_data\" -> 'aliases'))", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notification_settings": { + "name": "notification_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "use_legacy_mode": { + "name": "use_legacy_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "circuit_breaker_enabled": { + "name": "circuit_breaker_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "circuit_breaker_webhook": { + "name": "circuit_breaker_webhook", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "daily_leaderboard_enabled": { + "name": "daily_leaderboard_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "daily_leaderboard_webhook": { + "name": "daily_leaderboard_webhook", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "daily_leaderboard_time": { + "name": "daily_leaderboard_time", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false, + "default": "'09:00'" + }, + "daily_leaderboard_top_n": { + "name": "daily_leaderboard_top_n", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 5 + }, + "cost_alert_enabled": { + "name": "cost_alert_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "cost_alert_webhook": { + "name": "cost_alert_webhook", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "cost_alert_threshold": { + "name": "cost_alert_threshold", + "type": "numeric(5, 2)", + "primaryKey": false, + "notNull": false, + "default": "'0.80'" + }, + "cost_alert_check_interval": { + "name": "cost_alert_check_interval", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 60 + }, + "cache_hit_rate_alert_enabled": { + "name": "cache_hit_rate_alert_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "cache_hit_rate_alert_webhook": { + "name": "cache_hit_rate_alert_webhook", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "cache_hit_rate_alert_window_mode": { + "name": "cache_hit_rate_alert_window_mode", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false, + "default": "'auto'" + }, + "cache_hit_rate_alert_check_interval": { + "name": "cache_hit_rate_alert_check_interval", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 5 + }, + "cache_hit_rate_alert_historical_lookback_days": { + "name": "cache_hit_rate_alert_historical_lookback_days", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 7 + }, + "cache_hit_rate_alert_min_eligible_requests": { + "name": "cache_hit_rate_alert_min_eligible_requests", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 20 + }, + "cache_hit_rate_alert_min_eligible_tokens": { + "name": "cache_hit_rate_alert_min_eligible_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "cache_hit_rate_alert_abs_min": { + "name": "cache_hit_rate_alert_abs_min", + "type": "numeric(5, 4)", + "primaryKey": false, + "notNull": false, + "default": "'0.05'" + }, + "cache_hit_rate_alert_drop_rel": { + "name": "cache_hit_rate_alert_drop_rel", + "type": "numeric(5, 4)", + "primaryKey": false, + "notNull": false, + "default": "'0.3'" + }, + "cache_hit_rate_alert_drop_abs": { + "name": "cache_hit_rate_alert_drop_abs", + "type": "numeric(5, 4)", + "primaryKey": false, + "notNull": false, + "default": "'0.1'" + }, + "cache_hit_rate_alert_cooldown_minutes": { + "name": "cache_hit_rate_alert_cooldown_minutes", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 30 + }, + "cache_hit_rate_alert_top_n": { + "name": "cache_hit_rate_alert_top_n", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 10 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notification_target_bindings": { + "name": "notification_target_bindings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "notification_type": { + "name": "notification_type", + "type": "notification_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "schedule_cron": { + "name": "schedule_cron", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "schedule_timezone": { + "name": "schedule_timezone", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "template_override": { + "name": "template_override", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "unique_notification_target_binding": { + "name": "unique_notification_target_binding", + "columns": [ + { + "expression": "notification_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_notification_bindings_type": { + "name": "idx_notification_bindings_type", + "columns": [ + { + "expression": "notification_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_notification_bindings_target": { + "name": "idx_notification_bindings_target", + "columns": [ + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "notification_target_bindings_target_id_webhook_targets_id_fk": { + "name": "notification_target_bindings_target_id_webhook_targets_id_fk", + "tableFrom": "notification_target_bindings", + "tableTo": "webhook_targets", + "columnsFrom": [ + "target_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.provider_batch_apply_operations": { + "name": "provider_batch_apply_operations", + "schema": "", + "columns": { + "claim_key": { + "name": "claim_key", + "type": "varchar(256)", + "primaryKey": true, + "notNull": true + }, + "preview_token": { + "name": "preview_token", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true + }, + "payload_fingerprint": { + "name": "payload_fingerprint", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "operation_id": { + "name": "operation_id", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true + }, + "undo_token": { + "name": "undo_token", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true + }, + "undo_expires_at": { + "name": "undo_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "undo_consumed_at": { + "name": "undo_consumed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "uniq_provider_batch_apply_operations_preview_token": { + "name": "uniq_provider_batch_apply_operations_preview_token", + "columns": [ + { + "expression": "preview_token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "uniq_provider_batch_apply_operations_operation_id": { + "name": "uniq_provider_batch_apply_operations_operation_id", + "columns": [ + { + "expression": "operation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "uniq_provider_batch_apply_operations_undo_token": { + "name": "uniq_provider_batch_apply_operations_undo_token", + "columns": [ + { + "expression": "undo_token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_batch_apply_operations_expires_at": { + "name": "idx_provider_batch_apply_operations_expires_at", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.provider_cache_effectiveness": { + "name": "provider_cache_effectiveness", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "cache_ttl_bucket": { + "name": "cache_ttl_bucket", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true + }, + "window_start": { + "name": "window_start", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "window_end": { + "name": "window_end", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "sample_count": { + "name": "sample_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "eligible_count": { + "name": "eligible_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "theoretical_cache_tokens": { + "name": "theoretical_cache_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "observed_cache_read_tokens": { + "name": "observed_cache_read_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "raw_effectiveness_bp": { + "name": "raw_effectiveness_bp", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "confidence_bp": { + "name": "confidence_bp", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "effectiveness_bp": { + "name": "effectiveness_bp", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_provider_cache_effectiveness_window": { + "name": "idx_provider_cache_effectiveness_window", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "model", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "window_start", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.provider_endpoint_probe_logs": { + "name": "provider_endpoint_probe_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "endpoint_id": { + "name": "endpoint_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'scheduled'" + }, + "ok": { + "name": "ok", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "latency_ms": { + "name": "latency_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "error_type": { + "name": "error_type", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_provider_endpoint_probe_logs_endpoint_created_at": { + "name": "idx_provider_endpoint_probe_logs_endpoint_created_at", + "columns": [ + { + "expression": "endpoint_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_endpoint_probe_logs_created_at": { + "name": "idx_provider_endpoint_probe_logs_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "provider_endpoint_probe_logs_endpoint_id_provider_endpoints_id_fk": { + "name": "provider_endpoint_probe_logs_endpoint_id_provider_endpoints_id_fk", + "tableFrom": "provider_endpoint_probe_logs", + "tableTo": "provider_endpoints", + "columnsFrom": [ + "endpoint_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.provider_endpoints": { + "name": "provider_endpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "vendor_id": { + "name": "vendor_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "provider_type": { + "name": "provider_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'claude'" + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_probed_at": { + "name": "last_probed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_probe_ok": { + "name": "last_probe_ok", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "last_probe_status_code": { + "name": "last_probe_status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_probe_latency_ms": { + "name": "last_probe_latency_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_probe_error_type": { + "name": "last_probe_error_type", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "last_probe_error_message": { + "name": "last_probe_error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "uniq_provider_endpoints_vendor_type_url": { + "name": "uniq_provider_endpoints_vendor_type_url", + "columns": [ + { + "expression": "vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "url", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"provider_endpoints\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_endpoints_vendor_type": { + "name": "idx_provider_endpoints_vendor_type", + "columns": [ + { + "expression": "vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"provider_endpoints\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_endpoints_enabled": { + "name": "idx_provider_endpoints_enabled", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"provider_endpoints\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_endpoints_pick_enabled": { + "name": "idx_provider_endpoints_pick_enabled", + "columns": [ + { + "expression": "vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"provider_endpoints\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_endpoints_created_at": { + "name": "idx_provider_endpoints_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_endpoints_deleted_at": { + "name": "idx_provider_endpoints_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "provider_endpoints_vendor_id_provider_vendors_id_fk": { + "name": "provider_endpoints_vendor_id_provider_vendors_id_fk", + "tableFrom": "provider_endpoints", + "tableTo": "provider_vendors", + "columnsFrom": [ + "vendor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.provider_groups": { + "name": "provider_groups", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "cost_multiplier": { + "name": "cost_multiplier", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": true, + "default": "'1.0'" + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "provider_groups_name_unique": { + "name": "provider_groups_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.provider_vendors": { + "name": "provider_vendors", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "website_domain": { + "name": "website_domain", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "website_url": { + "name": "website_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "favicon_url": { + "name": "favicon_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "uniq_provider_vendors_website_domain": { + "name": "uniq_provider_vendors_website_domain", + "columns": [ + { + "expression": "website_domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_vendors_created_at": { + "name": "idx_provider_vendors_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.providers": { + "name": "providers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "url": { + "name": "url", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "provider_vendor_id": { + "name": "provider_vendor_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "weight": { + "name": "weight", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "group_priorities": { + "name": "group_priorities", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'null'::jsonb" + }, + "cost_multiplier": { + "name": "cost_multiplier", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": false, + "default": "'1.0'" + }, + "group_tag": { + "name": "group_tag", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "provider_type": { + "name": "provider_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'claude'" + }, + "preserve_client_ip": { + "name": "preserve_client_ip", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "disable_session_reuse": { + "name": "disable_session_reuse", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "model_redirects": { + "name": "model_redirects", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "allowed_models": { + "name": "allowed_models", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'null'::jsonb" + }, + "allowed_clients": { + "name": "allowed_clients", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "blocked_clients": { + "name": "blocked_clients", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "active_time_start": { + "name": "active_time_start", + "type": "varchar(5)", + "primaryKey": false, + "notNull": false + }, + "active_time_end": { + "name": "active_time_end", + "type": "varchar(5)", + "primaryKey": false, + "notNull": false + }, + "codex_instructions_strategy": { + "name": "codex_instructions_strategy", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false, + "default": "'auto'" + }, + "mcp_passthrough_type": { + "name": "mcp_passthrough_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "mcp_passthrough_url": { + "name": "mcp_passthrough_url", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "limit_5h_usd": { + "name": "limit_5h_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_5h_reset_mode": { + "name": "limit_5h_reset_mode", + "type": "daily_reset_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'rolling'" + }, + "limit_daily_usd": { + "name": "limit_daily_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "daily_reset_mode": { + "name": "daily_reset_mode", + "type": "daily_reset_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'fixed'" + }, + "daily_reset_time": { + "name": "daily_reset_time", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true, + "default": "'00:00'" + }, + "limit_weekly_usd": { + "name": "limit_weekly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_monthly_usd": { + "name": "limit_monthly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_total_usd": { + "name": "limit_total_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "total_cost_reset_at": { + "name": "total_cost_reset_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "limit_concurrent_sessions": { + "name": "limit_concurrent_sessions", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "max_retry_attempts": { + "name": "max_retry_attempts", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "circuit_breaker_failure_threshold": { + "name": "circuit_breaker_failure_threshold", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 5 + }, + "circuit_breaker_open_duration": { + "name": "circuit_breaker_open_duration", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 1800000 + }, + "circuit_breaker_half_open_success_threshold": { + "name": "circuit_breaker_half_open_success_threshold", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 2 + }, + "proxy_url": { + "name": "proxy_url", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "proxy_fallback_to_direct": { + "name": "proxy_fallback_to_direct", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "custom_headers": { + "name": "custom_headers", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "first_byte_timeout_streaming_ms": { + "name": "first_byte_timeout_streaming_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "streaming_idle_timeout_ms": { + "name": "streaming_idle_timeout_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "request_timeout_non_streaming_ms": { + "name": "request_timeout_non_streaming_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "website_url": { + "name": "website_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "favicon_url": { + "name": "favicon_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cache_ttl_preference": { + "name": "cache_ttl_preference", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "swap_cache_ttl_billing": { + "name": "swap_cache_ttl_billing", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "context_1m_preference": { + "name": "context_1m_preference", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "codex_reasoning_effort_preference": { + "name": "codex_reasoning_effort_preference", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "codex_reasoning_summary_preference": { + "name": "codex_reasoning_summary_preference", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "codex_text_verbosity_preference": { + "name": "codex_text_verbosity_preference", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "codex_parallel_tool_calls_preference": { + "name": "codex_parallel_tool_calls_preference", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "codex_image_generation_preference": { + "name": "codex_image_generation_preference", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "codex_service_tier_preference": { + "name": "codex_service_tier_preference", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "anthropic_max_tokens_preference": { + "name": "anthropic_max_tokens_preference", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "anthropic_thinking_budget_preference": { + "name": "anthropic_thinking_budget_preference", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "anthropic_adaptive_thinking": { + "name": "anthropic_adaptive_thinking", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'null'::jsonb" + }, + "gemini_google_search_preference": { + "name": "gemini_google_search_preference", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "tpm": { + "name": "tpm", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "rpm": { + "name": "rpm", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "rpd": { + "name": "rpd", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "cc": { + "name": "cc", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_providers_enabled_priority": { + "name": "idx_providers_enabled_priority", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "weight", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"providers\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_providers_group": { + "name": "idx_providers_group", + "columns": [ + { + "expression": "group_tag", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"providers\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_providers_vendor_type_url_active": { + "name": "idx_providers_vendor_type_url_active", + "columns": [ + { + "expression": "provider_vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "url", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"providers\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_providers_created_at": { + "name": "idx_providers_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_providers_deleted_at": { + "name": "idx_providers_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_providers_vendor_type": { + "name": "idx_providers_vendor_type", + "columns": [ + { + "expression": "provider_vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"providers\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_providers_enabled_vendor_type": { + "name": "idx_providers_enabled_vendor_type", + "columns": [ + { + "expression": "provider_vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"providers\".\"deleted_at\" IS NULL AND \"providers\".\"is_enabled\" = true AND \"providers\".\"provider_vendor_id\" IS NOT NULL AND \"providers\".\"provider_vendor_id\" > 0", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "providers_provider_vendor_id_provider_vendors_id_fk": { + "name": "providers_provider_vendor_id_provider_vendors_id_fk", + "tableFrom": "providers", + "tableTo": "provider_vendors", + "columnsFrom": [ + "provider_vendor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.replay_payloads": { + "name": "replay_payloads", + "schema": "", + "columns": { + "replay_id": { + "name": "replay_id", + "type": "varchar(64)", + "primaryKey": true, + "notNull": true + }, + "verifier": { + "name": "verifier", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "scope_tag": { + "name": "scope_tag", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true + }, + "key_id": { + "name": "key_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "format": { + "name": "format", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "headers_json": { + "name": "headers_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "byte_size": { + "name": "byte_size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "source_message_request_id": { + "name": "source_message_request_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "idx_replay_payloads_key_id": { + "name": "idx_replay_payloads_key_id", + "columns": [ + { + "expression": "key_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_replay_payloads_expires_at": { + "name": "idx_replay_payloads_expires_at", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.request_filters": { + "name": "request_filters", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true + }, + "match_type": { + "name": "match_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "target": { + "name": "target", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "replacement": { + "name": "replacement", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "binding_type": { + "name": "binding_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'global'" + }, + "provider_ids": { + "name": "provider_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "group_tags": { + "name": "group_tags", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "rule_mode": { + "name": "rule_mode", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'simple'" + }, + "execution_phase": { + "name": "execution_phase", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'guard'" + }, + "operations": { + "name": "operations", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_request_filters_enabled": { + "name": "idx_request_filters_enabled", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_request_filters_scope": { + "name": "idx_request_filters_scope", + "columns": [ + { + "expression": "scope", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_request_filters_action": { + "name": "idx_request_filters_action", + "columns": [ + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_request_filters_binding": { + "name": "idx_request_filters_binding", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "binding_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_request_filters_phase": { + "name": "idx_request_filters_phase", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_phase", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sensitive_words": { + "name": "sensitive_words", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "word": { + "name": "word", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "match_type": { + "name": "match_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'contains'" + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_sensitive_words_enabled": { + "name": "idx_sensitive_words_enabled", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "match_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_sensitive_words_created_at": { + "name": "idx_sensitive_words_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.system_settings": { + "name": "system_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "site_title": { + "name": "site_title", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "default": "'Claude Code Hub'" + }, + "allow_global_usage_view": { + "name": "allow_global_usage_view", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "currency_display": { + "name": "currency_display", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true, + "default": "'USD'" + }, + "billing_model_source": { + "name": "billing_model_source", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'original'" + }, + "codex_priority_billing_source": { + "name": "codex_priority_billing_source", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'requested'" + }, + "bill_non_successful_requests": { + "name": "bill_non_successful_requests", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "bill_hedge_losers": { + "name": "bill_hedge_losers", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "discovery_enabled": { + "name": "discovery_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "discovery_concurrency": { + "name": "discovery_concurrency", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 2 + }, + "max_discovery_rounds": { + "name": "max_discovery_rounds", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 2 + }, + "discovery_sla_ms": { + "name": "discovery_sla_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10000 + }, + "sticky_sla_ms": { + "name": "sticky_sla_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 20000 + }, + "racing_total_timeout_ms": { + "name": "racing_total_timeout_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 60000 + }, + "sticky_timeout_cooldown_ms": { + "name": "sticky_timeout_cooldown_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 300000 + }, + "timezone": { + "name": "timezone", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "enable_auto_cleanup": { + "name": "enable_auto_cleanup", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "cleanup_retention_days": { + "name": "cleanup_retention_days", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 30 + }, + "cleanup_schedule": { + "name": "cleanup_schedule", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "default": "'0 2 * * *'" + }, + "cleanup_batch_size": { + "name": "cleanup_batch_size", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 10000 + }, + "enable_client_version_check": { + "name": "enable_client_version_check", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "verbose_provider_error": { + "name": "verbose_provider_error", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "pass_through_upstream_error_message": { + "name": "pass_through_upstream_error_message", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_http2": { + "name": "enable_http2", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "enable_openai_responses_websocket": { + "name": "enable_openai_responses_websocket", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_high_concurrency_mode": { + "name": "enable_high_concurrency_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "intercept_anthropic_warmup_requests": { + "name": "intercept_anthropic_warmup_requests", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "enable_thinking_signature_rectifier": { + "name": "enable_thinking_signature_rectifier", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_thinking_budget_rectifier": { + "name": "enable_thinking_budget_rectifier", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_thinking_effort_conflict_rectifier": { + "name": "enable_thinking_effort_conflict_rectifier", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_gemini_function_id_rectifier": { + "name": "enable_gemini_function_id_rectifier", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_billing_header_rectifier": { + "name": "enable_billing_header_rectifier", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_response_input_rectifier": { + "name": "enable_response_input_rectifier", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "allow_non_conversation_endpoint_provider_fallback": { + "name": "allow_non_conversation_endpoint_provider_fallback", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "fake_streaming_whitelist": { + "name": "fake_streaming_whitelist", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "enable_codex_session_id_completion": { + "name": "enable_codex_session_id_completion", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_claude_metadata_user_id_injection": { + "name": "enable_claude_metadata_user_id_injection", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_response_fixer": { + "name": "enable_response_fixer", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "response_fixer_config": { + "name": "response_fixer_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{\"fixTruncatedJson\":true,\"fixSseFormat\":true,\"fixEncoding\":true,\"maxJsonDepth\":200,\"maxFixSize\":1048576}'::jsonb" + }, + "quota_db_refresh_interval_seconds": { + "name": "quota_db_refresh_interval_seconds", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 10 + }, + "quota_lease_percent_5h": { + "name": "quota_lease_percent_5h", + "type": "numeric(5, 4)", + "primaryKey": false, + "notNull": false, + "default": "'0.05'" + }, + "quota_lease_percent_daily": { + "name": "quota_lease_percent_daily", + "type": "numeric(5, 4)", + "primaryKey": false, + "notNull": false, + "default": "'0.05'" + }, + "quota_lease_percent_weekly": { + "name": "quota_lease_percent_weekly", + "type": "numeric(5, 4)", + "primaryKey": false, + "notNull": false, + "default": "'0.05'" + }, + "quota_lease_percent_monthly": { + "name": "quota_lease_percent_monthly", + "type": "numeric(5, 4)", + "primaryKey": false, + "notNull": false, + "default": "'0.05'" + }, + "quota_lease_cap_usd": { + "name": "quota_lease_cap_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "ip_extraction_config": { + "name": "ip_extraction_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "ip_geo_lookup_enabled": { + "name": "ip_geo_lookup_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "public_status_window_hours": { + "name": "public_status_window_hours", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 24 + }, + "public_status_aggregation_interval_minutes": { + "name": "public_status_aggregation_interval_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 5 + }, + "stream_gate_mode": { + "name": "stream_gate_mode", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true, + "default": "'enforce'" + }, + "affinity_ignore_client_session_id": { + "name": "affinity_ignore_client_session_id", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "replay_enabled": { + "name": "replay_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "cache_effectiveness_enabled": { + "name": "cache_effectiveness_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.usage_ledger": { + "name": "usage_ledger", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "request_id": { + "name": "request_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "final_provider_id": { + "name": "final_provider_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "original_model": { + "name": "original_model", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "actual_response_model": { + "name": "actual_response_model", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "endpoint": { + "name": "endpoint", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "api_type": { + "name": "api_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "session_id": { + "name": "session_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "is_success": { + "name": "is_success", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "success_rate_outcome": { + "name": "success_rate_outcome", + "type": "varchar(16)", + "primaryKey": false, + "notNull": false + }, + "blocked_by": { + "name": "blocked_by", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "cost_usd": { + "name": "cost_usd", + "type": "numeric(21, 15)", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "cost_multiplier": { + "name": "cost_multiplier", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": false + }, + "group_cost_multiplier": { + "name": "group_cost_multiplier", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": false + }, + "input_tokens": { + "name": "input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "output_tokens": { + "name": "output_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_creation_input_tokens": { + "name": "cache_creation_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_read_input_tokens": { + "name": "cache_read_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_creation_5m_input_tokens": { + "name": "cache_creation_5m_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_creation_1h_input_tokens": { + "name": "cache_creation_1h_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_ttl_applied": { + "name": "cache_ttl_applied", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "context_1m_applied": { + "name": "context_1m_applied", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "swap_cache_ttl_applied": { + "name": "swap_cache_ttl_applied", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "ttfb_ms": { + "name": "ttfb_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "first_byte_ms": { + "name": "first_byte_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "client_ip": { + "name": "client_ip", + "type": "varchar(45)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "idx_usage_ledger_request_id": { + "name": "idx_usage_ledger_request_id", + "columns": [ + { + "expression": "request_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_usage_ledger_user_created_at": { + "name": "idx_usage_ledger_user_created_at", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_ledger\".\"blocked_by\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_usage_ledger_key_created_at": { + "name": "idx_usage_ledger_key_created_at", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_ledger\".\"blocked_by\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_usage_ledger_provider_created_at": { + "name": "idx_usage_ledger_provider_created_at", + "columns": [ + { + "expression": "final_provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_ledger\".\"blocked_by\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_usage_ledger_created_at_minute": { + "name": "idx_usage_ledger_created_at_minute", + "columns": [ + { + "expression": "date_trunc('minute', \"created_at\" AT TIME ZONE 'UTC')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_usage_ledger_created_at_desc_id": { + "name": "idx_usage_ledger_created_at_desc_id", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_usage_ledger_session_id": { + "name": "idx_usage_ledger_session_id", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_ledger\".\"session_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_usage_ledger_model": { + "name": "idx_usage_ledger_model", + "columns": [ + { + "expression": "model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_ledger\".\"model\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_usage_ledger_key_cost": { + "name": "idx_usage_ledger_key_cost", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_usd", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "endpoint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_ledger\".\"blocked_by\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_usage_ledger_user_cost_cover": { + "name": "idx_usage_ledger_user_cost_cover", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_usd", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "endpoint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_ledger\".\"blocked_by\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_usage_ledger_provider_cost_cover": { + "name": "idx_usage_ledger_provider_cost_cover", + "columns": [ + { + "expression": "final_provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_usd", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "endpoint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_ledger\".\"blocked_by\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_usage_ledger_key_created_at_desc_cover": { + "name": "idx_usage_ledger_key_created_at_desc_cover", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"created_at\" DESC NULLS LAST", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "final_provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_ledger\".\"blocked_by\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "varchar", + "primaryKey": false, + "notNull": false, + "default": "'user'" + }, + "rpm_limit": { + "name": "rpm_limit", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "daily_limit_usd": { + "name": "daily_limit_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "provider_group": { + "name": "provider_group", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false, + "default": "'default'" + }, + "tags": { + "name": "tags", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "limit_5h_usd": { + "name": "limit_5h_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_5h_reset_mode": { + "name": "limit_5h_reset_mode", + "type": "daily_reset_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'rolling'" + }, + "limit_weekly_usd": { + "name": "limit_weekly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_monthly_usd": { + "name": "limit_monthly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_total_usd": { + "name": "limit_total_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "cost_reset_at": { + "name": "cost_reset_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "limit_5h_cost_reset_at": { + "name": "limit_5h_cost_reset_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "limit_concurrent_sessions": { + "name": "limit_concurrent_sessions", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "daily_reset_mode": { + "name": "daily_reset_mode", + "type": "daily_reset_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'fixed'" + }, + "daily_reset_time": { + "name": "daily_reset_time", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true, + "default": "'00:00'" + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "allowed_clients": { + "name": "allowed_clients", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "allowed_models": { + "name": "allowed_models", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "blocked_clients": { + "name": "blocked_clients", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_users_active_role_sort": { + "name": "idx_users_active_role_sort", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"users\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_users_enabled_expires_at": { + "name": "idx_users_enabled_expires_at", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"users\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_users_tags_gin": { + "name": "idx_users_tags_gin", + "columns": [ + { + "expression": "tags", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"users\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_users_created_at": { + "name": "idx_users_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_users_deleted_at": { + "name": "idx_users_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhook_targets": { + "name": "webhook_targets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "provider_type": { + "name": "provider_type", + "type": "webhook_provider_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "webhook_url": { + "name": "webhook_url", + "type": "varchar(1024)", + "primaryKey": false, + "notNull": false + }, + "telegram_bot_token": { + "name": "telegram_bot_token", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "telegram_chat_id": { + "name": "telegram_chat_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "dingtalk_secret": { + "name": "dingtalk_secret", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "custom_template": { + "name": "custom_template", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "custom_headers": { + "name": "custom_headers", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "proxy_url": { + "name": "proxy_url", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "proxy_fallback_to_direct": { + "name": "proxy_fallback_to_direct", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_test_at": { + "name": "last_test_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_test_result": { + "name": "last_test_result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.daily_reset_mode": { + "name": "daily_reset_mode", + "schema": "public", + "values": [ + "fixed", + "rolling" + ] + }, + "public.notification_type": { + "name": "notification_type", + "schema": "public", + "values": [ + "circuit_breaker", + "daily_leaderboard", + "cost_alert", + "cache_hit_rate_alert" + ] + }, + "public.webhook_provider_type": { + "name": "webhook_provider_type", + "schema": "public", + "values": [ + "wechat", + "feishu", + "dingtalk", + "telegram", + "custom" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index d5f102ac7..1f2b7527e 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -799,6 +799,13 @@ "when": 1784833275913, "tag": "0113_reflective_centennial", "breakpoints": true + }, + { + "idx": 114, + "version": "7", + "when": 1784952018550, + "tag": "0114_overconfident_ronan", + "breakpoints": true } ] } \ No newline at end of file diff --git a/messages/en/dashboard.json b/messages/en/dashboard.json index 96ff6a8c6..5fbc5d622 100644 --- a/messages/en/dashboard.json +++ b/messages/en/dashboard.json @@ -348,25 +348,25 @@ "performance": { "title": "Performance", "ttfb": "TTFB", + "tfft": "TFFT", "duration": "Total Duration", - "outputRate": "Output Rate" + "outputRate": "Output Rate", + "outputTokens": "Output Tokens" }, "performanceTab": { "noPerformanceData": "No performance data available", - "ttfbGauge": "Time to First Byte", + "tfftGauge": "Time to First Token", "outputRateGauge": "Output Rate", "latencyBreakdown": "Latency Breakdown", "generationTime": "Generation Time", + "segmentTtfb": "TTFB", + "segmentTfft": "Token Wait", + "segmentTotal": "Total", "assessment": { "excellent": "Excellent", "good": "Good", "warning": "Warning", "poor": "Poor" - }, - "thresholds": { - "ttfbGood": "TTFB < 1s", - "ttfbWarning": "TTFB 1-2s", - "ttfbPoor": "TTFB > 3s" } }, "metadata": { @@ -678,7 +678,7 @@ "totalConsumedAmount": "Total Spend", "successRate": "Success Rate", "avgResponseTime": "Avg Response Time", - "avgTtfbMs": "Avg TTFB", + "avgTtfbMs": "Avg TFFT", "avgTokensPerSecond": "Avg tok/s", "avgCostPerRequest": "Avg Cost/Req", "avgCostPerMillionTokens": "Avg Cost/1M Tokens", diff --git a/messages/en/settings/statusPage.json b/messages/en/settings/statusPage.json index 5eae1b710..38d4d2fc8 100644 --- a/messages/en/settings/statusPage.json +++ b/messages/en/settings/statusPage.json @@ -3,7 +3,7 @@ "description": "Configure the public status page's stats window, chart bucket size, and which groups/models should be exposed.", "form": { "windowHours": "Stats Window (hours)", - "windowHoursDesc": "Window length used to compute TTFB and availability, and the total span the chart covers. Default: 24 hours.", + "windowHoursDesc": "Window length used to compute TFFT and availability, and the total span the chart covers. Default: 24 hours.", "aggregationIntervalMinutes": "Chart Bucket (minutes)", "aggregationIntervalMinutesDesc": "Length of each bucket on the chart timeline; controls chart granularity. Choose 5 / 15 / 30 / 60 minutes.", "aggregationIntervalMinutesInvalid": "Public status aggregation interval must be one of 5, 15, 30, or 60 minutes.", @@ -46,7 +46,7 @@ "heroPrimary": "AI SERVICES", "heroSecondary": "SERVICE STATUS DASHBOARD", "generatedAt": "Updated", - "ttfb": "TTFB", + "ttfb": "TFFT", "tps": "TPS", "history": "History", "freshnessWindow": "Snapshot freshness", @@ -76,7 +76,7 @@ }, "tooltip": { "availability": "Availability", - "ttfb": "TTFB", + "ttfb": "TFFT", "tps": "TPS", "samples": "Samples", "inferredFromNeighbors": "No requests in this window — inferred from neighbors", diff --git a/messages/ja/dashboard.json b/messages/ja/dashboard.json index 617593896..2176634b5 100644 --- a/messages/ja/dashboard.json +++ b/messages/ja/dashboard.json @@ -348,25 +348,25 @@ "performance": { "title": "パフォーマンス", "ttfb": "TTFB", + "tfft": "TFFT", "duration": "総所要時間", - "outputRate": "出力速度" + "outputRate": "出力速度", + "outputTokens": "出力トークン" }, "performanceTab": { "noPerformanceData": "パフォーマンスデータがありません", - "ttfbGauge": "初バイト到達時間", + "tfftGauge": "初トークン到達時間", "outputRateGauge": "出力速度", "latencyBreakdown": "レイテンシ内訳", "generationTime": "生成時間", + "segmentTtfb": "TTFB", + "segmentTfft": "トークン待機", + "segmentTotal": "合計", "assessment": { "excellent": "優秀", "good": "良好", "warning": "警告", "poor": "不良" - }, - "thresholds": { - "ttfbGood": "TTFB < 1s", - "ttfbWarning": "TTFB 1-2s", - "ttfbPoor": "TTFB > 3s" } }, "metadata": { @@ -678,7 +678,7 @@ "totalConsumedAmount": "総消費額", "successRate": "成功率(%)", "avgResponseTime": "平均応答時間", - "avgTtfbMs": "平均TTFB", + "avgTtfbMs": "平均TFFT", "avgTokensPerSecond": "平均トークン/秒", "avgCostPerRequest": "平均リクエスト単価", "avgCostPerMillionTokens": "100万トークンあたりコスト", diff --git a/messages/ja/settings/statusPage.json b/messages/ja/settings/statusPage.json index f41ea87b7..e6574d771 100644 --- a/messages/ja/settings/statusPage.json +++ b/messages/ja/settings/statusPage.json @@ -3,7 +3,7 @@ "description": "公開ステータスページの統計ウィンドウ、チャートのバケットサイズ、および公開するグループ/モデルを設定します。", "form": { "windowHours": "統計ウィンドウ(時間)", - "windowHoursDesc": "TTFB と可用率の算出に使う統計期間であり、チャートがカバーする総期間でもあります。既定値は 24 時間です。", + "windowHoursDesc": "TFFT と可用率の算出に使う統計期間であり、チャートがカバーする総期間でもあります。既定値は 24 時間です。", "aggregationIntervalMinutes": "チャートのバケット(分)", "aggregationIntervalMinutesDesc": "チャート時間軸の各バケットの長さで、チャートの粒度を決定します。5 / 15 / 30 / 60 分から選択できます。", "aggregationIntervalMinutesInvalid": "公開ステータスの集計間隔は 5、15、30、60 分のいずれかである必要があります。", @@ -46,7 +46,7 @@ "heroPrimary": "AI SERVICES", "heroSecondary": "SERVICE STATUS DASHBOARD", "generatedAt": "更新", - "ttfb": "TTFB", + "ttfb": "TFFT", "tps": "TPS", "history": "履歴", "freshnessWindow": "スナップショット有効期限", @@ -76,7 +76,7 @@ }, "tooltip": { "availability": "可用率", - "ttfb": "TTFB", + "ttfb": "TFFT", "tps": "TPS", "samples": "サンプル数", "inferredFromNeighbors": "この期間はリクエストがないため、隣接データから推定", diff --git a/messages/ru/dashboard.json b/messages/ru/dashboard.json index 1a5bf7db0..454fe0803 100644 --- a/messages/ru/dashboard.json +++ b/messages/ru/dashboard.json @@ -348,25 +348,25 @@ "performance": { "title": "Производительность", "ttfb": "TTFB", + "tfft": "TFFT", "duration": "Общее время", - "outputRate": "Скорость вывода" + "outputRate": "Скорость вывода", + "outputTokens": "Токены вывода" }, "performanceTab": { "noPerformanceData": "Нет данных о производительности", - "ttfbGauge": "Время до первого байта", + "tfftGauge": "Время до первого токена", "outputRateGauge": "Скорость вывода", "latencyBreakdown": "Разбивка задержки", "generationTime": "Время генерации", + "segmentTtfb": "TTFB", + "segmentTfft": "Ожидание токена", + "segmentTotal": "Всего", "assessment": { "excellent": "Отлично", "good": "Хорошо", "warning": "Предупреждение", "poor": "Плохо" - }, - "thresholds": { - "ttfbGood": "TTFB < 1с", - "ttfbWarning": "TTFB 1-2с", - "ttfbPoor": "TTFB > 3с" } }, "metadata": { @@ -678,7 +678,7 @@ "totalConsumedAmount": "Общие расходы", "successRate": "Процент успеха", "avgResponseTime": "Среднее время ответа", - "avgTtfbMs": "Средний TTFB", + "avgTtfbMs": "Средний TFFT", "avgTokensPerSecond": "Средн. ток/с", "avgCostPerRequest": "Ср. стоимость/запрос", "avgCostPerMillionTokens": "Ср. стоимость/1М токенов", diff --git a/messages/ru/settings/statusPage.json b/messages/ru/settings/statusPage.json index d7bb45386..640c0984d 100644 --- a/messages/ru/settings/statusPage.json +++ b/messages/ru/settings/statusPage.json @@ -3,7 +3,7 @@ "description": "Настройте окно статистики, размер бакета графика и группы/модели, которые будут показаны публично.", "form": { "windowHours": "Окно статистики (часы)", - "windowHoursDesc": "Длина окна для расчёта TTFB и доступности, а также общий период, покрываемый графиком. По умолчанию: 24 часа.", + "windowHoursDesc": "Длина окна для расчёта TFFT и доступности, а также общий период, покрываемый графиком. По умолчанию: 24 часа.", "aggregationIntervalMinutes": "Бакет графика (минуты)", "aggregationIntervalMinutesDesc": "Длина каждого бакета на оси времени графика; определяет детализацию графика. Допустимые значения: 5 / 15 / 30 / 60 минут.", "aggregationIntervalMinutesInvalid": "Интервал агрегации публичного статуса должен быть одним из 5, 15, 30 или 60 минут.", @@ -46,7 +46,7 @@ "heroPrimary": "AI SERVICES", "heroSecondary": "SERVICE STATUS DASHBOARD", "generatedAt": "Обновлено", - "ttfb": "TTFB", + "ttfb": "TFFT", "tps": "TPS", "history": "История", "freshnessWindow": "Свежесть снимка", @@ -76,7 +76,7 @@ }, "tooltip": { "availability": "Доступность", - "ttfb": "TTFB", + "ttfb": "TFFT", "tps": "TPS", "samples": "Выборки", "inferredFromNeighbors": "Запросов нет — состояние выведено из соседних интервалов", diff --git a/messages/zh-CN/dashboard.json b/messages/zh-CN/dashboard.json index c3b8dbc16..f314c58ec 100644 --- a/messages/zh-CN/dashboard.json +++ b/messages/zh-CN/dashboard.json @@ -348,25 +348,25 @@ "performance": { "title": "性能数据", "ttfb": "首字节时间(TTFB)", + "tfft": "首 Token 时间(TFFT)", "duration": "总耗时", - "outputRate": "输出速率" + "outputRate": "输出速率", + "outputTokens": "输出 Tokens" }, "performanceTab": { "noPerformanceData": "暂无性能数据", - "ttfbGauge": "首字节时间", + "tfftGauge": "首 Token 时间", "outputRateGauge": "输出速率", "latencyBreakdown": "延迟分解", "generationTime": "生成时间", + "segmentTtfb": "TTFB", + "segmentTfft": "等待首 Token", + "segmentTotal": "总计", "assessment": { "excellent": "优秀", "good": "良好", "warning": "警告", "poor": "较差" - }, - "thresholds": { - "ttfbGood": "TTFB < 1s", - "ttfbWarning": "TTFB 1-2s", - "ttfbPoor": "TTFB > 3s" } }, "metadata": { @@ -678,7 +678,7 @@ "totalConsumedAmount": "总消耗金额", "successRate": "成功率", "avgResponseTime": "平均响应时间", - "avgTtfbMs": "平均 TTFB", + "avgTtfbMs": "平均 TFFT", "avgTokensPerSecond": "平均输出速率", "avgCostPerRequest": "平均单次请求成本", "avgCostPerMillionTokens": "平均百万 Token 成本", diff --git a/messages/zh-CN/settings/statusPage.json b/messages/zh-CN/settings/statusPage.json index 8019df3b0..992c4f387 100644 --- a/messages/zh-CN/settings/statusPage.json +++ b/messages/zh-CN/settings/statusPage.json @@ -3,7 +3,7 @@ "description": "配置公开状态页面的统计窗口、图表分桶,以及需要对外展示的分组和模型。", "form": { "windowHours": "统计窗口(小时)", - "windowHoursDesc": "用于计算 TTFB 与在线率的统计窗口长度,也是图表覆盖的总时间跨度。默认 24 小时。", + "windowHoursDesc": "用于计算 TFFT 与在线率的统计窗口长度,也是图表覆盖的总时间跨度。默认 24 小时。", "aggregationIntervalMinutes": "图表分桶(分钟)", "aggregationIntervalMinutesDesc": "图表时间线每个分桶的时长,决定图表粒度。可选 5 / 15 / 30 / 60 分钟。", "aggregationIntervalMinutesInvalid": "公开状态聚合间隔只能是 5、15、30、60 分钟之一。", @@ -46,7 +46,7 @@ "heroPrimary": "AI 服务", "heroSecondary": "服务状态面板", "generatedAt": "更新于", - "ttfb": "TTFB", + "ttfb": "TFFT", "tps": "TPS", "history": "历史", "freshnessWindow": "快照新鲜期剩余", @@ -76,7 +76,7 @@ }, "tooltip": { "availability": "可用率", - "ttfb": "TTFB", + "ttfb": "TFFT", "tps": "TPS", "samples": "样本数", "inferredFromNeighbors": "该时段无请求,根据相邻时段状态推断", diff --git a/messages/zh-TW/dashboard.json b/messages/zh-TW/dashboard.json index f2cbb699f..e8e5fee6f 100644 --- a/messages/zh-TW/dashboard.json +++ b/messages/zh-TW/dashboard.json @@ -348,25 +348,25 @@ "performance": { "title": "效能資料", "ttfb": "首字節時間(TTFB)", + "tfft": "首 Token 時間(TFFT)", "duration": "總耗時", - "outputRate": "輸出速率" + "outputRate": "輸出速率", + "outputTokens": "輸出 Tokens" }, "performanceTab": { "noPerformanceData": "暫無效能資料", - "ttfbGauge": "首字節時間", + "tfftGauge": "首 Token 時間", "outputRateGauge": "輸出速率", "latencyBreakdown": "延遲分解", "generationTime": "生成時間", + "segmentTtfb": "TTFB", + "segmentTfft": "等待首 Token", + "segmentTotal": "總計", "assessment": { "excellent": "優秀", "good": "良好", "warning": "警告", "poor": "較差" - }, - "thresholds": { - "ttfbGood": "TTFB < 1s", - "ttfbWarning": "TTFB 1-2s", - "ttfbPoor": "TTFB > 3s" } }, "metadata": { @@ -678,7 +678,7 @@ "totalConsumedAmount": "總消耗金額", "successRate": "成功率(%)", "avgResponseTime": "平均回覆時間", - "avgTtfbMs": "平均 TTFB(ms)", + "avgTtfbMs": "平均 TFFT(ms)", "avgTokensPerSecond": "平均輸出速率", "avgCostPerRequest": "平均每次請求成本", "avgCostPerMillionTokens": "平均每百萬 Token 成本", diff --git a/messages/zh-TW/settings/statusPage.json b/messages/zh-TW/settings/statusPage.json index 2c8ef1ec3..c6d879e49 100644 --- a/messages/zh-TW/settings/statusPage.json +++ b/messages/zh-TW/settings/statusPage.json @@ -3,7 +3,7 @@ "description": "設定公開狀態頁面的統計視窗、圖表分桶,以及需要對外展示的分組和模型。", "form": { "windowHours": "統計視窗(小時)", - "windowHoursDesc": "用於計算 TTFB 與在線率的統計視窗長度,也是圖表覆蓋的總時間跨度。預設 24 小時。", + "windowHoursDesc": "用於計算 TFFT 與在線率的統計視窗長度,也是圖表覆蓋的總時間跨度。預設 24 小時。", "aggregationIntervalMinutes": "圖表分桶(分鐘)", "aggregationIntervalMinutesDesc": "圖表時間軸每個分桶的時長,決定圖表粒度。可選 5 / 15 / 30 / 60 分鐘。", "aggregationIntervalMinutesInvalid": "公開狀態聚合間隔只能是 5、15、30、60 分鐘之一。", @@ -46,7 +46,7 @@ "heroPrimary": "AI 服務", "heroSecondary": "服務狀態面板", "generatedAt": "更新於", - "ttfb": "TTFB", + "ttfb": "TFFT", "tps": "TPS", "history": "歷史", "freshnessWindow": "快照新鮮期剩餘", @@ -76,7 +76,7 @@ }, "tooltip": { "availability": "可用率", - "ttfb": "TTFB", + "ttfb": "TFFT", "tps": "TPS", "samples": "樣本數", "inferredFromNeighbors": "該時段無請求,依相鄰時段狀態推斷", diff --git a/src/app/[locale]/dashboard/logs/_components/error-details-dialog.test.tsx b/src/app/[locale]/dashboard/logs/_components/error-details-dialog.test.tsx index 64b0bc03b..66f1e9ff6 100644 --- a/src/app/[locale]/dashboard/logs/_components/error-details-dialog.test.tsx +++ b/src/app/[locale]/dashboard/logs/_components/error-details-dialog.test.tsx @@ -255,26 +255,26 @@ const messages = { performance: { title: "Performance", ttfb: "TTFB", + tfft: "TFFT", duration: "Duration", outputRate: "Output rate", + outputTokens: "Output Tokens", }, performanceTab: { noPerformanceData: "No performance data", - ttfbGauge: "Time to First Byte", + tfftGauge: "Time to First Token", outputRateGauge: "Output Rate", latencyBreakdown: "Latency Breakdown", generationTime: "Generation Time", + segmentTtfb: "TTFB", + segmentTfft: "Token Wait", + segmentTotal: "Total", assessment: { excellent: "Excellent", good: "Good", warning: "Warning", poor: "Poor", }, - thresholds: { - ttfbGood: "TTFB < 300ms", - ttfbWarning: "TTFB 300-600ms", - ttfbPoor: "TTFB > 1000ms", - }, }, metadata: { noMetadata: "No metadata", @@ -526,7 +526,8 @@ describe("error-details-dialog layout", () => { inputTokens={100} outputTokens={80} durationMs={900} - ttfbMs={100} + tfftMs={100} + firstByteMs={100} /> ); @@ -546,7 +547,8 @@ describe("error-details-dialog layout", () => { inputTokens={100} outputTokens={0} durationMs={null} - ttfbMs={null} + tfftMs={null} + firstByteMs={null} /> ); @@ -566,7 +568,8 @@ describe("error-details-dialog layout", () => { inputTokens={null} outputTokens={80} durationMs={900} - ttfbMs={100} + tfftMs={100} + firstByteMs={100} /> ); @@ -576,7 +579,7 @@ describe("error-details-dialog layout", () => { test("hides tok/s when TTFB is close to duration and rate is abnormally high", () => { // Rule: generationTimeMs / durationMs < 0.1 && outputRate > 5000 => hide tok/s - // durationMs=1000, ttfbMs=950 => generationTimeMs=50, ratio=0.05 < 0.1 + // durationMs=1000, firstByteMs=950 => generationTimeMs=50, ratio=0.05 < 0.1 // outputTokens=300 => rate = 300 / 0.05 = 6000 > 5000 => should hide const html = renderWithIntl( { inputTokens={null} outputTokens={300} durationMs={1000} - ttfbMs={950} + tfftMs={950} + firstByteMs={950} /> ); @@ -601,7 +605,7 @@ describe("error-details-dialog layout", () => { }); test("shows tok/s in dialog when conditions are normal", () => { - // durationMs=1000, ttfbMs=500 => generationTimeMs=500, ratio=0.5 >= 0.1 + // durationMs=1000, firstByteMs=500 => generationTimeMs=500, ratio=0.5 >= 0.1 // outputTokens=50 => rate = 50 / 0.5 = 100 <= 5000 => should show const html = renderWithIntl( { inputTokens={null} outputTokens={50} durationMs={1000} - ttfbMs={500} + tfftMs={500} + firstByteMs={500} /> ); @@ -1194,12 +1199,13 @@ describe("error-details-dialog tabs", () => { providerChain={null} sessionId={null} durationMs={1000} - ttfbMs={200} + tfftMs={200} + firstByteMs={200} outputTokens={500} /> ); - expect(html).toContain("Time to First Byte"); + expect(html).toContain("Time to First Token"); expect(html).toContain("Output Rate"); expect(html).toContain("Latency Breakdown"); }); diff --git a/src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/LatencyBreakdownBar.tsx b/src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/LatencyBreakdownBar.tsx index ce89e537a..48e56939a 100644 --- a/src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/LatencyBreakdownBar.tsx +++ b/src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/LatencyBreakdownBar.tsx @@ -4,8 +4,10 @@ import { useTranslations } from "next-intl"; import { cn } from "@/lib/utils"; interface LatencyBreakdownBarProps { - /** Time to first byte in milliseconds */ - ttfbMs: number | null; + /** Time to first byte in milliseconds (null on rows persisted before it was recorded) */ + firstByteMs: number | null; + /** Time to first token in milliseconds */ + tfftMs: number | null; /** Total duration in milliseconds */ durationMs: number | null; /** Optional className */ @@ -22,7 +24,8 @@ function formatMs(ms: number): string { } export function LatencyBreakdownBar({ - ttfbMs, + firstByteMs, + tfftMs, durationMs, className, showLabels = true, @@ -31,70 +34,89 @@ export function LatencyBreakdownBar({ // Handle null/invalid values if ( - ttfbMs === null || + tfftMs === null || durationMs === null || - ttfbMs < 0 || + tfftMs < 0 || durationMs <= 0 || - ttfbMs > durationMs + tfftMs > durationMs ) { return null; } - const generationMs = durationMs - ttfbMs; - const ttfbPercent = (ttfbMs / durationMs) * 100; - const generationPercent = 100 - ttfbPercent; + // 历史行没有真 TTFB:首段退化为整个 TFFT,中间段消失 + const ttfbMs = + firstByteMs !== null && firstByteMs >= 0 && firstByteMs <= tfftMs ? firstByteMs : tfftMs; + const tokenWaitMs = tfftMs - ttfbMs; + const generationMs = durationMs - tfftMs; + const percent = (ms: number) => (ms / durationMs) * 100; // Minimum width for visibility (3%) const minWidth = 3; - const adjustedTtfbPercent = Math.max(ttfbPercent, ttfbMs > 0 ? minWidth : 0); - const adjustedGenerationPercent = Math.max(generationPercent, generationMs > 0 ? minWidth : 0); + const width = (ms: number) => Math.max(percent(ms), ms > 0 ? minWidth : 0); + + const segments = [ + { + key: "ttfb", + ms: ttfbMs, + label: t("segmentTtfb"), + barClass: "bg-blue-500", + dotClass: "bg-blue-500", + }, + { + key: "tokenWait", + ms: tokenWaitMs, + label: t("segmentTfft"), + barClass: "bg-violet-500", + dotClass: "bg-violet-500", + }, + { + key: "generation", + ms: generationMs, + label: t("generationTime"), + barClass: "bg-emerald-500", + dotClass: "bg-emerald-500", + }, + ]; return (
{/* Bar container */}
- {/* TTFB segment */} - {ttfbMs > 0 && ( -
- {ttfbPercent >= 15 && TTFB} -
- )} - - {/* Generation segment */} - {generationMs > 0 && ( -
- {generationPercent >= 15 && Generation} -
+ {segments.map((segment) => + segment.ms > 0 ? ( +
+ {percent(segment.ms) >= 15 && {segment.label}} +
+ ) : null )}
{/* Labels */} {showLabels && ( -
-
-
- TTFB: - {formatMs(ttfbMs)} -
-
-
- {t("generationTime")}: - {formatMs(generationMs)} -
+
+ {segments.map((segment) => + segment.ms > 0 ? ( +
+
+ {segment.label}: + {formatMs(segment.ms)} +
+ ) : null + )}
)} {/* Total */}
- Total: {formatMs(durationMs)} + {t("segmentTotal")}: {formatMs(durationMs)}
); diff --git a/src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/PerformanceTab.tsx b/src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/PerformanceTab.tsx index a83522a44..b7d73813f 100644 --- a/src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/PerformanceTab.tsx +++ b/src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/PerformanceTab.tsx @@ -5,35 +5,36 @@ import { useTranslations } from "next-intl"; import { Badge } from "@/components/ui/badge"; import { CircularProgress } from "@/components/ui/circular-progress"; import { cn, formatTokenAmount } from "@/lib/utils"; -import { calculateOutputRate, type PerformanceTabProps, shouldHideOutputRate } from "../types"; +import { calculateOutputRate, shouldHideOutputRate } from "@/lib/utils/performance-formatter"; +import type { PerformanceTabProps } from "../types"; import { LatencyBreakdownBar } from "./LatencyBreakdownBar"; /** - * Get TTFB performance assessment + * Get TFFT performance assessment * Thresholds: <1s excellent, <2s good, <3s warning, >=3s poor */ -function getTtfbAssessment(ttfbMs: number | null): { +function getTfftAssessment(tfftMs: number | null): { label: string; color: string; bgColor: string; } | null { - if (ttfbMs === null) return null; + if (tfftMs === null) return null; - if (ttfbMs < 1000) { + if (tfftMs < 1000) { return { label: "excellent", color: "text-emerald-600", bgColor: "bg-emerald-50 dark:bg-emerald-950/20", }; } - if (ttfbMs < 2000) { + if (tfftMs < 2000) { return { label: "good", color: "text-blue-600", bgColor: "bg-blue-50 dark:bg-blue-950/20", }; } - if (ttfbMs < 3000) { + if (tfftMs < 3000) { return { label: "warning", color: "text-amber-600", @@ -85,31 +86,38 @@ function getOutputRateAssessment(rate: number | null): { }; } -export function PerformanceTab({ durationMs, ttfbMs, outputTokens }: PerformanceTabProps) { +export function PerformanceTab({ + durationMs, + tfftMs, + firstByteMs, + outputTokens, +}: PerformanceTabProps) { const t = useTranslations("dashboard.logs.details"); // Normalize undefined to null for consistent handling const normalizedDurationMs = durationMs ?? null; - const normalizedTtfbMs = ttfbMs ?? null; + const normalizedTfftMs = tfftMs ?? null; + const normalizedFirstByteMs = firstByteMs ?? null; const normalizedOutputTokens = outputTokens ?? null; const outputRate = calculateOutputRate( normalizedOutputTokens, normalizedDurationMs, - normalizedTtfbMs + normalizedFirstByteMs ); - const hideRate = shouldHideOutputRate(outputRate, normalizedDurationMs, normalizedTtfbMs); + const hideRate = shouldHideOutputRate(outputRate, normalizedDurationMs, normalizedFirstByteMs); const generationMs = - normalizedDurationMs !== null && normalizedTtfbMs !== null - ? normalizedDurationMs - normalizedTtfbMs + normalizedDurationMs !== null && normalizedTfftMs !== null + ? normalizedDurationMs - normalizedTfftMs : null; - const ttfbAssessment = getTtfbAssessment(normalizedTtfbMs); + const tfftAssessment = getTfftAssessment(normalizedTfftMs); const rateAssessment = getOutputRateAssessment(outputRate); const hasData = normalizedDurationMs !== null || - normalizedTtfbMs !== null || + normalizedTfftMs !== null || + normalizedFirstByteMs !== null || (outputRate !== null && !hideRate) || normalizedOutputTokens !== null; @@ -126,17 +134,17 @@ export function PerformanceTab({ durationMs, ttfbMs, outputTokens }: Performance
{/* Gauges Row */}
- {/* TTFB Gauge */} - {normalizedTtfbMs !== null && ( + {/* TFFT Gauge */} + {normalizedTfftMs !== null && (
-

{t("performanceTab.ttfbGauge")}

+

{t("performanceTab.tfftGauge")}

- {normalizedTtfbMs >= 1000 - ? `${(normalizedTtfbMs / 1000).toFixed(2)}s` - : `${Math.round(normalizedTtfbMs)}ms`} + {normalizedTfftMs >= 1000 + ? `${(normalizedTfftMs / 1000).toFixed(2)}s` + : `${Math.round(normalizedTfftMs)}ms`}

- {ttfbAssessment && ( - - {t(`performanceTab.assessment.${ttfbAssessment.label}`)} + {tfftAssessment && ( + + {t(`performanceTab.assessment.${tfftAssessment.label}`)} )}
@@ -198,14 +206,18 @@ export function PerformanceTab({ durationMs, ttfbMs, outputTokens }: Performance
{/* Latency Breakdown Bar */} - {normalizedTtfbMs !== null && normalizedDurationMs !== null && ( + {normalizedTfftMs !== null && normalizedDurationMs !== null && (

{t("performanceTab.latencyBreakdown")}

- +
)} @@ -214,13 +226,23 @@ export function PerformanceTab({ durationMs, ttfbMs, outputTokens }: Performance

{t("performance.title")}

- {normalizedTtfbMs !== null && ( + {normalizedFirstByteMs !== null && (
{t("performance.ttfb")} - {normalizedTtfbMs >= 1000 - ? `${(normalizedTtfbMs / 1000).toFixed(2)}s` - : `${Math.round(normalizedTtfbMs)}ms`} + {normalizedFirstByteMs >= 1000 + ? `${(normalizedFirstByteMs / 1000).toFixed(2)}s` + : `${Math.round(normalizedFirstByteMs)}ms`} + +
+ )} + {normalizedTfftMs !== null && ( +
+ {t("performance.tfft")} + + {normalizedTfftMs >= 1000 + ? `${(normalizedTfftMs / 1000).toFixed(2)}s` + : `${Math.round(normalizedTfftMs)}ms`}
)} @@ -248,7 +270,7 @@ export function PerformanceTab({ durationMs, ttfbMs, outputTokens }: Performance )} {normalizedOutputTokens !== null && (
- Output Tokens + {t("performance.outputTokens")} {formatTokenAmount(normalizedOutputTokens)} diff --git a/src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/SummaryTab.tsx b/src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/SummaryTab.tsx index 7e70d4679..50f344ace 100644 --- a/src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/SummaryTab.tsx +++ b/src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/SummaryTab.tsx @@ -29,6 +29,7 @@ import { cn, formatTokenAmount } from "@/lib/utils"; import { formatCurrency } from "@/lib/utils/currency"; import { buildHedgeBillingTable } from "@/lib/utils/hedge-billing"; import { resolveModelAuditDisplay } from "@/lib/utils/model-audit-display"; +import { calculateOutputRate, shouldHideOutputRate } from "@/lib/utils/performance-formatter"; import { getPricingResolutionSpecialSetting, getThinkingSignatureModelDetectionSpecialSetting, @@ -37,13 +38,7 @@ import { import { extractThinkingEffortInfo } from "@/lib/utils/thinking-effort"; import { getFake200ReasonKey } from "../../fake200-reason"; import { Fake200RetryTooltip } from "../../fake200-retry-tooltip"; -import { - calculateOutputRate, - isInProgressStatus, - isSuccessStatus, - type SummaryTabProps, - shouldHideOutputRate, -} from "../types"; +import { isInProgressStatus, isSuccessStatus, type SummaryTabProps } from "../types"; export function SummaryTab({ statusCode, @@ -69,7 +64,7 @@ export function SummaryTab({ routingTrace, context1mApplied, durationMs, - ttfbMs, + firstByteMs, sessionId, requestSequence, userAgent, @@ -85,8 +80,12 @@ export function SummaryTab({ const isSuccess = isSuccessStatus(statusCode); const isInProgress = isInProgressStatus(statusCode); - const outputRate = calculateOutputRate(outputTokens, durationMs, ttfbMs); - const hideRate = shouldHideOutputRate(outputRate, durationMs, ttfbMs); + const outputRate = calculateOutputRate( + outputTokens ?? null, + durationMs ?? null, + firstByteMs ?? null + ); + const hideRate = shouldHideOutputRate(outputRate, durationMs ?? null, firstByteMs ?? null); const totalTokens = (inputTokens ?? 0) + (outputTokens ?? 0); const hasRedirect = originalModel && currentModel && originalModel !== currentModel; const modelAudit = resolveModelAuditDisplay({ diff --git a/src/app/[locale]/dashboard/logs/_components/error-details-dialog/index.tsx b/src/app/[locale]/dashboard/logs/_components/error-details-dialog/index.tsx index 901dd1a75..d690debf6 100644 --- a/src/app/[locale]/dashboard/logs/_components/error-details-dialog/index.tsx +++ b/src/app/[locale]/dashboard/logs/_components/error-details-dialog/index.tsx @@ -49,7 +49,8 @@ interface ErrorDetailsDialogProps { hedgeLosers?: HedgeLoserBilling[] | null; context1mApplied?: boolean | null; durationMs?: number | null; - ttfbMs?: number | null; + tfftMs?: number | null; + firstByteMs?: number | null; externalOpen?: boolean; onExternalOpenChange?: (open: boolean) => void; scrollToRedirect?: boolean; @@ -94,7 +95,8 @@ export function ErrorDetailsDialog({ hedgeLosers, context1mApplied, durationMs, - ttfbMs, + tfftMs, + firstByteMs, externalOpen, onExternalOpenChange, scrollToRedirect, @@ -244,7 +246,8 @@ export function ErrorDetailsDialog({ hedgeLosers, context1mApplied, durationMs, - ttfbMs, + tfftMs, + firstByteMs, }; return ( diff --git a/src/app/[locale]/dashboard/logs/_components/error-details-dialog/types.ts b/src/app/[locale]/dashboard/logs/_components/error-details-dialog/types.ts index d68695b61..a5b95850e 100644 --- a/src/app/[locale]/dashboard/logs/_components/error-details-dialog/types.ts +++ b/src/app/[locale]/dashboard/logs/_components/error-details-dialog/types.ts @@ -72,8 +72,10 @@ export interface TabSharedProps { context1mApplied?: boolean | null; /** Total request duration in ms */ durationMs?: number | null; - /** Time to first byte in ms */ - ttfbMs?: number | null; + /** Time to first token in ms */ + tfftMs?: number | null; + /** Time to first byte in ms (null on rows persisted before it was recorded) */ + firstByteMs?: number | null; } /** @@ -130,56 +132,6 @@ export function parseBlockedReason(blockedReason: string | null | undefined): { } } -/** - * Calculate output tokens per second - */ -export function calculateOutputRate( - outputTokens: number | null | undefined, - durationMs: number | null | undefined, - ttfbMs: number | null | undefined -): number | null { - if ( - outputTokens === null || - outputTokens === undefined || - outputTokens <= 0 || - durationMs === null || - durationMs === undefined || - ttfbMs === null || - ttfbMs === undefined || - ttfbMs >= durationMs - ) { - return null; - } - const seconds = (durationMs - ttfbMs) / 1000; - if (seconds <= 0) return null; - return outputTokens / seconds; -} - -/** - * Determine if output rate should be hidden due to blocked streaming request. - * Rule: Hide when generationTimeMs / durationMs < 0.1 AND outputRate > 5000 - * This indicates TTFB is very close to total duration with abnormally high tok/s. - */ -export function shouldHideOutputRate( - outputRate: number | null, - durationMs: number | null | undefined, - ttfbMs: number | null | undefined -): boolean { - if ( - outputRate == null || - !Number.isFinite(outputRate) || - durationMs == null || - durationMs <= 0 || - ttfbMs == null - ) { - return false; - } - const generationTimeMs = durationMs - ttfbMs; - if (generationTimeMs <= 0) return false; - const ratio = generationTimeMs / durationMs; - return ratio < 0.1 && outputRate > 5000; -} - /** * Check if request is successful (2xx status) */ diff --git a/src/app/[locale]/dashboard/logs/_components/usage-logs-table.test.tsx b/src/app/[locale]/dashboard/logs/_components/usage-logs-table.test.tsx index 7b9dd0f0a..301ed256a 100644 --- a/src/app/[locale]/dashboard/logs/_components/usage-logs-table.test.tsx +++ b/src/app/[locale]/dashboard/logs/_components/usage-logs-table.test.tsx @@ -79,7 +79,8 @@ function makeLog(overrides: Partial): UsageLogRow { costBreakdown: null, hedgeLosers: null, durationMs: 100, - ttfbMs: 50, + tfftMs: 50, + firstByteMs: 50, errorMessage: null, providerChain: null, blockedBy: null, @@ -414,11 +415,13 @@ describe("usage-logs-table multiplier badge", () => { test("hides tok/s when TTFB is close to duration and rate is abnormally high", () => { // Rule: generationTimeMs / durationMs < 0.1 && outputRate > 5000 => hide tok/s - // durationMs=1000, ttfbMs=950 => generationTimeMs=50, ratio=0.05 < 0.1 + // durationMs=1000, firstByteMs=950 => generationTimeMs=50, ratio=0.05 < 0.1 // outputTokens=300 => rate = 300 / 0.05 = 6000 > 5000 => should hide const html = renderToStaticMarkup( { // tok/s should NOT appear expect(html).not.toContain("tok/s"); - // TTFB should still appear - expect(html).toContain("TTFB"); + // TFFT 行仍应出现 + expect(html).toContain("logs.details.performance.tfft"); }); test("shows tok/s when conditions are normal", () => { - // durationMs=1000, ttfbMs=500 => generationTimeMs=500, ratio=0.5 >= 0.1 + // durationMs=1000, firstByteMs=500 => generationTimeMs=500, ratio=0.5 >= 0.1 // outputTokens=50 => rate = 50 / 0.5 = 100 <= 5000 => should show const html = renderToStaticMarkup( { // tok/s should appear expect(html).toContain("tok/s"); - // TTFB should also appear - expect(html).toContain("TTFB"); + // TFFT 行同样应出现 + expect(html).toContain("logs.details.performance.tfft"); }); test("renders swap indicator on cacheTtl badge when swapCacheTtlApplied is true", () => { diff --git a/src/app/[locale]/dashboard/logs/_components/usage-logs-table.tsx b/src/app/[locale]/dashboard/logs/_components/usage-logs-table.tsx index 880eae98a..866e31ab8 100644 --- a/src/app/[locale]/dashboard/logs/_components/usage-logs-table.tsx +++ b/src/app/[locale]/dashboard/logs/_components/usage-logs-table.tsx @@ -584,13 +584,17 @@ export function UsageLogsTable({ const rate = calculateOutputRate( log.outputTokens, log.durationMs, - log.ttfbMs + log.firstByteMs + ); + const hideRate = shouldHideOutputRate( + rate, + log.durationMs, + log.firstByteMs ); - const hideRate = shouldHideOutputRate(rate, log.durationMs, log.ttfbMs); const secondLine = [ - log.ttfbMs != null && - log.ttfbMs > 0 && - `TTFB ${formatDuration(log.ttfbMs)}`, + log.tfftMs != null && + log.tfftMs > 0 && + `${t("logs.details.performance.tfft")} ${formatDuration(log.tfftMs)}`, rate !== null && !hideRate && `${rate.toFixed(0)} tok/s`, ] .filter(Boolean) @@ -614,10 +618,16 @@ export function UsageLogsTable({ {t("logs.details.performance.duration")}:{" "} {formatDuration(log.durationMs)}
- {log.ttfbMs != null && ( + {log.tfftMs != null && ( +
+ {t("logs.details.performance.tfft")}:{" "} + {formatDuration(log.tfftMs)} +
+ )} + {log.firstByteMs != null && (
{t("logs.details.performance.ttfb")}:{" "} - {formatDuration(log.ttfbMs)} + {formatDuration(log.firstByteMs)}
)} {rate !== null && !hideRate && ( @@ -666,7 +676,8 @@ export function UsageLogsTable({ hedgeLosers={log.hedgeLosers} context1mApplied={log.context1mApplied} durationMs={log.durationMs} - ttfbMs={log.ttfbMs} + tfftMs={log.tfftMs} + firstByteMs={log.firstByteMs} externalOpen={dialogState.logId === log.id ? true : undefined} onExternalOpenChange={(open) => { if (!open) setDialogState({ logId: null, scrollToRedirect: false }); diff --git a/src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.test.tsx b/src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.test.tsx index a16e6c5e4..e7f57f5b2 100644 --- a/src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.test.tsx +++ b/src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.test.tsx @@ -143,7 +143,8 @@ function makeLog(overrides: Partial): UsageLogRow { costBreakdown: null, hedgeLosers: null, durationMs: 100, - ttfbMs: 50, + tfftMs: 50, + firstByteMs: 50, errorMessage: null, providerChain: null, blockedBy: null, @@ -492,17 +493,19 @@ describe("virtualized-logs-table multiplier badge", () => { mockIsFetchingNextPage = false; // Rule: generationTimeMs / durationMs < 0.1 && outputRate > 5000 => hide tok/s - // durationMs=1000, ttfbMs=950 => generationTimeMs=50, ratio=0.05 < 0.1 + // durationMs=1000, firstByteMs=950 => generationTimeMs=50, ratio=0.05 < 0.1 // outputTokens=300 => rate = 300 / 0.05 = 6000 > 5000 => should hide - mockLogs = [makeLog({ id: 1, durationMs: 1000, ttfbMs: 950, outputTokens: 300 })]; + mockLogs = [ + makeLog({ id: 1, durationMs: 1000, tfftMs: 950, firstByteMs: 950, outputTokens: 300 }), + ]; const html = renderToStaticMarkup( ); // tok/s should NOT appear expect(html).not.toContain("tok/s"); - // TTFB should still appear - expect(html).toContain("TTFB"); + // TFFT 行仍应出现 + expect(html).toContain("logs.details.performance.tfft"); }); test("shows tok/s when conditions are normal", () => { @@ -512,17 +515,19 @@ describe("virtualized-logs-table multiplier badge", () => { mockHasNextPage = false; mockIsFetchingNextPage = false; - // durationMs=1000, ttfbMs=500 => generationTimeMs=500, ratio=0.5 >= 0.1 + // durationMs=1000, firstByteMs=500 => generationTimeMs=500, ratio=0.5 >= 0.1 // outputTokens=50 => rate = 50 / 0.5 = 100 <= 5000 => should show - mockLogs = [makeLog({ id: 1, durationMs: 1000, ttfbMs: 500, outputTokens: 50 })]; + mockLogs = [ + makeLog({ id: 1, durationMs: 1000, tfftMs: 500, firstByteMs: 500, outputTokens: 50 }), + ]; const html = renderToStaticMarkup( ); // tok/s should appear expect(html).toContain("tok/s"); - // TTFB should also appear - expect(html).toContain("TTFB"); + // TFFT 行同样应出现 + expect(html).toContain("logs.details.performance.tfft"); }); test("renders swap indicator on cacheTtl badge when swapCacheTtlApplied is true", () => { diff --git a/src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.tsx b/src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.tsx index a8235282f..e0f150534 100644 --- a/src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.tsx +++ b/src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.tsx @@ -1157,12 +1157,16 @@ export function VirtualizedLogsTable({ const rate = calculateOutputRate( log.outputTokens, log.durationMs, - log.ttfbMs + log.firstByteMs ); - const hideRate = shouldHideOutputRate(rate, log.durationMs, log.ttfbMs); - const ttfbLine = - log.ttfbMs != null && log.ttfbMs > 0 - ? `TTFB ${formatDuration(log.ttfbMs)}` + const hideRate = shouldHideOutputRate( + rate, + log.durationMs, + log.firstByteMs + ); + const tfftLine = + log.tfftMs != null && log.tfftMs > 0 + ? `${t("logs.details.performance.tfft")} ${formatDuration(log.tfftMs)}` : null; const rateLine = rate !== null && !hideRate ? `${rate.toFixed(0)} tok/s` : null; @@ -1173,9 +1177,9 @@ export function VirtualizedLogsTable({
{formatDuration(log.durationMs)} - {ttfbLine && ( + {tfftLine && ( - {ttfbLine} + {tfftLine} )} {rateLine && ( @@ -1190,10 +1194,16 @@ export function VirtualizedLogsTable({ {t("logs.details.performance.duration")}:{" "} {formatDuration(log.durationMs)}
- {log.ttfbMs != null && ( + {log.tfftMs != null && ( +
+ {t("logs.details.performance.tfft")}:{" "} + {formatDuration(log.tfftMs)} +
+ )} + {log.firstByteMs != null && (
{t("logs.details.performance.ttfb")}:{" "} - {formatDuration(log.ttfbMs)} + {formatDuration(log.firstByteMs)}
)} {rate !== null && !hideRate && ( @@ -1248,7 +1258,8 @@ export function VirtualizedLogsTable({ hedgeLosers={log.hedgeLosers} context1mApplied={log.context1mApplied} durationMs={log.durationMs} - ttfbMs={log.ttfbMs} + tfftMs={log.tfftMs} + firstByteMs={log.firstByteMs} externalOpen={dialogState.logId === log.id ? true : undefined} onExternalOpenChange={(open) => { if (!open) setDialogState({ logId: null, scrollToRedirect: false }); diff --git a/src/app/v1/_lib/proxy/forwarder.ts b/src/app/v1/_lib/proxy/forwarder.ts index f587d8ba9..075fad513 100644 --- a/src/app/v1/_lib/proxy/forwarder.ts +++ b/src/app/v1/_lib/proxy/forwarder.ts @@ -333,6 +333,8 @@ type StreamingHedgeAttempt = { firstChunk: Uint8Array | null; /** F1 门控提交标记(该 attempt 门控提交时记录,随 hedge_winner 链条目落库)。 */ gateAudit?: ProviderChainItem["streamGate"]; + /** 该 attempt 首字节到达时刻(epoch ms);只有赢家的值会被记为 session TTFB。 */ + firstByteAt?: number | null; /** * Billing context snapshot for the INITIAL provider's losing attempt, captured BEFORE * commitWinner overwrites the shared session's model/context with the winner's. Null for @@ -1703,6 +1705,9 @@ export class ProxyForwarder { }; const gateReader = response.body.getReader(); const gateStartedAt = Date.now(); + // TTFB 只在门控提交后写入 session:提交前失败的尝试不会被服务, + // 记下它的首字节会低估 TTFB 并放大 TPS 的分母。 + let gateFirstByteAt: number | null = null; const gate = await runStreamContentGate(gateReader, { family: gateFamily, providerId: currentProvider.id, @@ -1710,7 +1715,10 @@ export class ProxyForwarder { ...resolveStreamGateCaps(), // 首字节到达即清除首字节计时器,保持「首字节超时」的原始语义—— // 思考型模型可在首个内容帧前长时间输出中性帧,不应触发该计时器 - onFirstByte: () => runtime.clearResponseTimeout?.(), + onFirstByte: () => { + gateFirstByteAt ??= Date.now(); + runtime.clearResponseTimeout?.(); + }, // 门控等待期沿用供应商静默超时(与提交后 response-handler 的行为对齐) idleTimeoutMs: currentProvider.streamingIdleTimeoutMs, captureCommitMarker: !session.isHighConcurrencyModeEnabled(), @@ -1754,6 +1762,10 @@ export class ProxyForwarder { throw gate.error; } + if (gateFirstByteAt !== null) { + session.recordFirstByte(gateFirstByteAt); + } + if (gate.commitMarker) { gateChainAudit = { ...gate.commitMarker, @@ -4723,6 +4735,10 @@ export class ProxyForwarder { providerId: attempt.provider.id, providerName: attempt.provider.name, ...resolveStreamGateCaps(), + // 首字节时刻先挂在 attempt 上,由 commitWinner 决定是否记为 session TTFB + onFirstByte: () => { + attempt.firstByteAt ??= Date.now(); + }, // 竞速路径首字节计时器已在响应头到达时清除;门控等待期沿用供应商静默超时 idleTimeoutMs: attempt.provider.streamingIdleTimeoutMs, captureCommitMarker: !session.isHighConcurrencyModeEnabled(), @@ -5035,6 +5051,10 @@ export class ProxyForwarder { winnerCommitted = true; winnerAttempt = attempt; + if (attempt.firstByteAt != null) { + session.recordFirstByte(attempt.firstByteAt); + } + if (attempt.thresholdTimer) { clearTimeout(attempt.thresholdTimer); attempt.thresholdTimer = null; @@ -6061,6 +6081,9 @@ export class ProxyForwarder { }) ); session.setProvider(attempt.provider); + if (attempt.firstByteAt != null) { + session.recordFirstByte(attempt.firstByteAt); + } if (attempt.session !== session) ProxyForwarder.syncWinningAttemptSession(session, attempt.session); @@ -6508,6 +6531,9 @@ export class ProxyForwarder { throw new EmptyResponseError(provider.id, provider.name, "empty_body"); } if (!item.value || item.value.byteLength === 0) continue; + // 首字节时刻先挂在 attempt 上;DiscoveryValidityParser 的 ready 判定同样基于内容, + // 不在此记录会让 discovery 模式的 TTFB 恒等于 TFFT。 + attempt.firstByteAt ??= Date.now(); attempt.chunks.push(item.value); const validity = attempt.parser.push(item.value); // A single read can contain both deliverable content and the diff --git a/src/app/v1/_lib/proxy/response-handler.ts b/src/app/v1/_lib/proxy/response-handler.ts index 5825ef903..9f0ae0b73 100644 --- a/src/app/v1/_lib/proxy/response-handler.ts +++ b/src/app/v1/_lib/proxy/response-handler.ts @@ -2603,7 +2603,8 @@ export class ProxyResponseHandler { details: { statusCode: finalizedStatusCode, ...errorDetails, - ttfbMs: session.ttfbMs ?? duration, + tfftMs: session.tfftMs ?? duration, + firstByteMs: session.firstByteMs ?? duration, providerChain: session.getProviderChain(), routingTrace: session.finalizeRoutingTrace(finalizedStatusCode), model: session.getCurrentModel() ?? undefined, @@ -2768,7 +2769,8 @@ export class ProxyResponseHandler { const terminalDetails: MessageRequestTerminalDetails = { statusCode: finalizedStatusCode, ...errorDetails, - ttfbMs: session.ttfbMs ?? duration, + tfftMs: session.tfftMs ?? duration, + firstByteMs: session.firstByteMs ?? duration, providerChain: session.getProviderChain(), routingTrace: session.finalizeRoutingTrace(finalizedStatusCode), model: session.getCurrentModel() ?? undefined, // 更新重定向后的模型 @@ -3078,7 +3080,8 @@ export class ProxyResponseHandler { statusCode: statusCode, inputTokens: usageMetrics?.input_tokens, outputTokens: usageMetrics?.output_tokens, - ttfbMs: session.ttfbMs ?? duration, + tfftMs: session.tfftMs ?? duration, + firstByteMs: session.firstByteMs ?? duration, cacheCreationInputTokens: usageMetrics?.cache_creation_input_tokens, cacheReadInputTokens: usageMetrics?.cache_read_input_tokens, cacheCreation5mInputTokens: usageMetrics?.cache_creation_5m_input_tokens, @@ -3524,7 +3527,7 @@ export class ProxyResponseHandler { clearIdleTimer(); if (isFirstChunk) { isFirstChunk = false; - session.recordTtfb(); + session.recordTfft(); clearResponseTimeoutOnce(value.byteLength); } streamTextAccumulator.pushBytes(value); @@ -4477,7 +4480,8 @@ export class ProxyResponseHandler { durationMs: duration, inputTokens: usageForCost?.input_tokens, outputTokens: usageForCost?.output_tokens, - ttfbMs: session.ttfbMs, + tfftMs: session.tfftMs, + firstByteMs: session.firstByteMs, cacheCreationInputTokens: usageForCost?.cache_creation_input_tokens, cacheReadInputTokens: usageForCost?.cache_read_input_tokens, cacheCreation5mInputTokens: usageForCost?.cache_creation_5m_input_tokens, @@ -4566,7 +4570,7 @@ export class ProxyResponseHandler { }); if (isFirstChunk) { - session.recordTtfb(); + session.recordTfft(); isFirstChunk = false; if (clearResponseTimeoutOnce()) { logger.debug("ResponseHandler: First chunk received, response timeout cleared", { @@ -6061,7 +6065,8 @@ export async function finalizeRequestStats( statusCode: statusCode, durationMs: duration, ...(errorMessage ? { errorMessage } : {}), - ttfbMs: session.ttfbMs ?? duration, + tfftMs: session.tfftMs ?? duration, + firstByteMs: session.firstByteMs ?? duration, providerChain: session.getProviderChain(), routingTrace: session.finalizeRoutingTrace(statusCode), model: session.getCurrentModel() ?? undefined, @@ -6174,7 +6179,8 @@ export async function finalizeRequestStats( durationMs: duration, inputTokens: normalizedUsage.input_tokens, outputTokens: normalizedUsage.output_tokens, - ttfbMs: session.ttfbMs ?? duration, + tfftMs: session.tfftMs ?? duration, + firstByteMs: session.firstByteMs ?? duration, cacheCreationInputTokens: normalizedUsage.cache_creation_input_tokens, cacheReadInputTokens: normalizedUsage.cache_read_input_tokens, cacheCreation5mInputTokens: normalizedUsage.cache_creation_5m_input_tokens, @@ -6432,7 +6438,8 @@ async function persistRequestFailure(options: { errorMessage, errorStack, errorCause, - ttfbMs: phase === "non-stream" ? (session.ttfbMs ?? duration) : session.ttfbMs, + tfftMs: phase === "non-stream" ? (session.tfftMs ?? duration) : session.tfftMs, + firstByteMs: phase === "non-stream" ? (session.firstByteMs ?? duration) : session.firstByteMs, providerChain: session.getProviderChain(), routingTrace: session.finalizeRoutingTrace(statusCode), model: session.getCurrentModel() ?? undefined, diff --git a/src/app/v1/_lib/proxy/session.ts b/src/app/v1/_lib/proxy/session.ts index 0c864af8e..46093ca29 100644 --- a/src/app/v1/_lib/proxy/session.ts +++ b/src/app/v1/_lib/proxy/session.ts @@ -142,8 +142,13 @@ export class ProxySession { provider: Provider | null; messageContext: MessageContext | null; - // Time To First Byte (ms). Streaming: first chunk. Non-stream: equals durationMs. - ttfbMs: number | null = null; + // Time To First Token (ms). Streaming: first chunk handed to the response handler, + // which under an enforcing stream gate is the first *content* frame. Non-stream: equals durationMs. + tfftMs: number | null = null; + + // Time To First Byte (ms). First body byte from the upstream, reported by the stream gate. + // Equals tfftMs whenever no gate ran (gate off/shadow, raw passthrough, non-SSE). + firstByteMs: number | null = null; // Timestamp when guard pipeline finished and forwarding started (epoch ms). forwardStartTime: number | null = null; @@ -552,22 +557,45 @@ export class ProxySession { } /** - * Record Time To First Byte (TTFB) for streaming responses. + * Record Time To First Token (TFFT) for streaming responses. + * + * Definition: first body chunk handed to the response handler. With the stream content + * gate enforcing, that chunk is the first content frame, so this is TFFT, not TTFB. + * Non-stream responses should persist TFFT as `durationMs` at finalize time. * - * Definition: first body chunk received. - * Non-stream responses should persist TTFB as `durationMs` at finalize time. + * Doubles as the TTFB fallback: paths where no gate ran never call `recordFirstByte`, + * and there TTFB and TFFT are the same moment. */ - recordTtfb(): number { - if (this.ttfbMs !== null) { - return this.ttfbMs; + recordTfft(): number { + if (this.tfftMs !== null) { + return this.tfftMs; } const value = Math.max(0, Date.now() - this.startTime); - this.ttfbMs = value; + this.tfftMs = value; + if (this.firstByteMs === null) { + this.firstByteMs = value; + } this.persistLiveChain(); return value; } + /** + * Record Time To First Byte (TTFB) from an upstream first-byte timestamp. + * + * Callers must only commit the timestamp of the attempt that actually gets served — + * committing a failed attempt's first byte would understate TTFB and inflate the + * generation window that TPS divides by. + */ + recordFirstByte(atEpochMs: number): void { + if (this.firstByteMs !== null) { + return; + } + + this.firstByteMs = Math.max(0, atEpochMs - this.startTime); + this.persistLiveChain(); + } + /** * Record the timestamp when guard pipeline finished and upstream forwarding begins. * Called once; subsequent calls are no-ops. @@ -952,7 +980,7 @@ export class ProxySession { outcome: resolvedOutcome, statusCode, durationMs: Math.max(0, now - this.routingTrace.startedAt), - ttfbMs: this.ttfbMs, + ttfbMs: this.tfftMs, }; } const terminalEvent = this.routingTrace.events.find( diff --git a/src/app/v1/_lib/proxy/warmup-guard.ts b/src/app/v1/_lib/proxy/warmup-guard.ts index 9dfb82e8f..997313ca7 100644 --- a/src/app/v1/_lib/proxy/warmup-guard.ts +++ b/src/app/v1/_lib/proxy/warmup-guard.ts @@ -80,7 +80,8 @@ export class ProxyWarmupGuard { messagesCount: session.getMessagesLength(), statusCode: 200, durationMs, - ttfbMs: durationMs, + tfftMs: durationMs, + firstByteMs: durationMs, // 不计费:显式写 NULL,避免前端误显示 “$0” costUsd: null, blockedBy: "warmup", diff --git a/src/drizzle/schema.ts b/src/drizzle/schema.ts index a4c0b6463..6ab894638 100644 --- a/src/drizzle/schema.ts +++ b/src/drizzle/schema.ts @@ -579,7 +579,11 @@ export const messageRequest = pgTable('message_request', { // Token 使用信息 inputTokens: bigint('input_tokens', { mode: 'number' }), outputTokens: bigint('output_tokens', { mode: 'number' }), - ttfbMs: integer('ttfb_ms'), + // 首 Token 时间(TFFT)。列名 ttfb_ms 是历史遗留:流式输出门禁上线后, + // 这个时间戳打在首个内容帧上,语义已是 TFFT 而非 TTFB。真 TTFB 见 firstByteMs。 + tfftMs: integer('ttfb_ms'), + // 首字节时间(TTFB):上游响应体第一个字节到达。门禁旁路时等于 tfftMs。 + firstByteMs: integer('first_byte_ms'), cacheCreationInputTokens: bigint('cache_creation_input_tokens', { mode: 'number' }), cacheReadInputTokens: bigint('cache_read_input_tokens', { mode: 'number' }), cacheCreation5mInputTokens: bigint('cache_creation_5m_input_tokens', { mode: 'number' }), @@ -1172,7 +1176,9 @@ export const usageLedger = pgTable('usage_ledger', { context1mApplied: boolean('context_1m_applied').default(false), swapCacheTtlApplied: boolean('swap_cache_ttl_applied').default(false), durationMs: integer('duration_ms'), - ttfbMs: integer('ttfb_ms'), + // 列名 ttfb_ms 存的是 TFFT,见 messageRequest.tfftMs 的说明 + tfftMs: integer('ttfb_ms'), + firstByteMs: integer('first_byte_ms'), // 客户端 IP(从 message_request 拷贝;永久保留,避免被清理任务删除) clientIp: varchar('client_ip', { length: 45 }), createdAt: timestamp('created_at', { withTimezone: true }).notNull(), diff --git a/src/lib/langfuse/emit-proxy-trace.ts b/src/lib/langfuse/emit-proxy-trace.ts index 7a0b0bc22..99d5fb508 100644 --- a/src/lib/langfuse/emit-proxy-trace.ts +++ b/src/lib/langfuse/emit-proxy-trace.ts @@ -77,7 +77,8 @@ function buildLangfuseSessionSnapshot(session: ProxySession): ProxySession { userAgent: session.userAgent, provider: session.provider, messageContext: session.messageContext, - ttfbMs: session.ttfbMs, + tfftMs: session.tfftMs, + firstByteMs: session.firstByteMs, forwardStartTime: session.forwardStartTime, forwardedRequestBody, sessionId: session.sessionId, diff --git a/src/lib/langfuse/trace-proxy-request.ts b/src/lib/langfuse/trace-proxy-request.ts index cf5d3e2d4..c69ab3441 100644 --- a/src/lib/langfuse/trace-proxy-request.ts +++ b/src/lib/langfuse/trace-proxy-request.ts @@ -190,11 +190,11 @@ export async function traceProxyRequest(ctx: TraceContext): Promise { guardPipelineMs, upstreamTotalMs: guardPipelineMs != null ? Math.max(0, durationMs - guardPipelineMs) : durationMs, - ttfbFromForwardMs: - guardPipelineMs != null && session.ttfbMs != null - ? Math.max(0, session.ttfbMs - guardPipelineMs) + tfftFromForwardMs: + guardPipelineMs != null && session.tfftMs != null + ? Math.max(0, session.tfftMs - guardPipelineMs) : null, - tokenGenerationMs: session.ttfbMs != null ? Math.max(0, durationMs - session.ttfbMs) : null, + tokenGenerationMs: session.tfftMs != null ? Math.max(0, durationMs - session.tfftMs) : null, failedAttempts: session.getProviderChain().filter((i) => !isSuccessReason(i.reason)).length, providersAttempted: new Set(session.getProviderChain().map((i) => i.id)).size, }; @@ -278,7 +278,8 @@ export async function traceProxyRequest(ctx: TraceContext): Promise { keyName: messageContext?.key?.name, // Timing durationMs, - ttfbMs: session.ttfbMs, + tfftMs: session.tfftMs, + firstByteMs: session.firstByteMs, timingBreakdown, // Flags isStreaming, @@ -433,9 +434,9 @@ export async function traceProxyRequest(ctx: TraceContext): Promise { ); // Set TTFB as completionStartTime - if (session.ttfbMs != null) { + if (session.tfftMs != null) { generation.update({ - completionStartTime: new Date(session.startTime + session.ttfbMs), + completionStartTime: new Date(session.startTime + session.tfftMs), }); } diff --git a/src/lib/ledger-backfill/service.ts b/src/lib/ledger-backfill/service.ts index c708a499a..e4a720cb1 100644 --- a/src/lib/ledger-backfill/service.ts +++ b/src/lib/ledger-backfill/service.ts @@ -91,6 +91,7 @@ export async function backfillUsageLedger( mr.swap_cache_ttl_applied, mr.duration_ms, mr.ttfb_ms, + mr.first_byte_ms, mr.created_at, ul.request_id AS existing_request_id FROM message_request mr @@ -121,7 +122,7 @@ export async function backfillUsageLedger( cache_creation_input_tokens, cache_read_input_tokens, cache_creation_5m_input_tokens, cache_creation_1h_input_tokens, cache_ttl_applied, context_1m_applied, swap_cache_ttl_applied, - duration_ms, ttfb_ms, created_at + duration_ms, ttfb_ms, first_byte_ms, created_at ) SELECT batch.id, @@ -152,6 +153,7 @@ export async function backfillUsageLedger( batch.swap_cache_ttl_applied, batch.duration_ms, batch.ttfb_ms, + batch.first_byte_ms, batch.created_at FROM batch ON CONFLICT (request_id) DO UPDATE SET diff --git a/src/lib/ledger-backfill/trigger.sql b/src/lib/ledger-backfill/trigger.sql index 06e1e2bef..7d474aaad 100644 --- a/src/lib/ledger-backfill/trigger.sql +++ b/src/lib/ledger-backfill/trigger.sql @@ -202,7 +202,7 @@ BEGIN cache_creation_input_tokens, cache_read_input_tokens, cache_creation_5m_input_tokens, cache_creation_1h_input_tokens, cache_ttl_applied, context_1m_applied, swap_cache_ttl_applied, - duration_ms, ttfb_ms, client_ip, created_at + duration_ms, ttfb_ms, first_byte_ms, client_ip, created_at ) VALUES ( NEW.id, NEW.user_id, NEW.key, NEW.provider_id, v_final_provider_id, NEW.model, NEW.original_model, NEW.actual_response_model, NEW.endpoint, NEW.api_type, NEW.session_id, @@ -212,7 +212,7 @@ BEGIN NEW.cache_creation_input_tokens, NEW.cache_read_input_tokens, NEW.cache_creation_5m_input_tokens, NEW.cache_creation_1h_input_tokens, NEW.cache_ttl_applied, NEW.context_1m_applied, NEW.swap_cache_ttl_applied, - NEW.duration_ms, NEW.ttfb_ms, NEW.client_ip, NEW.created_at + NEW.duration_ms, NEW.ttfb_ms, NEW.first_byte_ms, NEW.client_ip, NEW.created_at ) ON CONFLICT (request_id) DO UPDATE SET user_id = EXCLUDED.user_id, @@ -243,6 +243,7 @@ BEGIN swap_cache_ttl_applied = EXCLUDED.swap_cache_ttl_applied, duration_ms = EXCLUDED.duration_ms, ttfb_ms = EXCLUDED.ttfb_ms, + first_byte_ms = EXCLUDED.first_byte_ms, client_ip = EXCLUDED.client_ip; -- created_at deliberately NOT updated on conflict: it represents the -- original insert time of the ledger row, which is immutable by design. @@ -285,6 +286,7 @@ AFTER INSERT OR UPDATE OF swap_cache_ttl_applied, duration_ms, ttfb_ms, + first_byte_ms, client_ip, created_at ON message_request diff --git a/src/lib/public-status/aggregation-core.ts b/src/lib/public-status/aggregation-core.ts index a3b07d4d9..e16811863 100644 --- a/src/lib/public-status/aggregation-core.ts +++ b/src/lib/public-status/aggregation-core.ts @@ -13,10 +13,16 @@ export interface PublicStatusConfiguredGroup { }>; } +/** + * TPS = 输出 token / 生成窗口,生成窗口以真 TTFB 为起点。 + * + * firstByteMs 缺失即返回 null:流式门禁上线前的历史行只有 TFFT,用它当分母会排除 + * 上游排队/中性帧窗口,系统性高估 TPS。 + */ export function computeTokensPerSecond(input: { outputTokens?: number | null; durationMs?: number | null; - ttfbMs?: number | null; + firstByteMs?: number | null; }): number | null { if (!input.outputTokens || input.outputTokens <= 0) { return null; @@ -26,7 +32,11 @@ export function computeTokensPerSecond(input: { return null; } - const generationMs = input.durationMs - (input.ttfbMs ?? 0); + if (input.firstByteMs == null) { + return null; + } + + const generationMs = input.durationMs - input.firstByteMs; if (generationMs <= 0) { return null; } diff --git a/src/lib/public-status/aggregation.ts b/src/lib/public-status/aggregation.ts index 2ed6fdf22..b403e002d 100644 --- a/src/lib/public-status/aggregation.ts +++ b/src/lib/public-status/aggregation.ts @@ -39,7 +39,8 @@ export interface PublicStatusRequestRow { model?: string | null; originalModel?: string | null; durationMs?: number | null; - ttfbMs?: number | null; + tfftMs?: number | null; + firstByteMs?: number | null; outputTokens?: number | null; providerChain?: PublicStatusRequestChainItem[] | null; } @@ -304,7 +305,7 @@ export function buildPublicStatusPayloadFromRequests(input: { const tps = computeTokensPerSecond({ outputTokens: request.outputTokens, durationMs: request.durationMs, - ttfbMs: request.ttfbMs, + firstByteMs: request.firstByteMs, }); for (const [sourceGroupName, outcome] of groupOutcome.entries()) { @@ -323,8 +324,9 @@ export function buildPublicStatusPayloadFromRequests(input: { bucket.failureCount += 1; } - if (outcome === "success" && typeof request.ttfbMs === "number") { - bucket.ttfbValues.push(request.ttfbMs); + // ttfbValues -> bucket.ttfbMs 是对外 payload 字段,装的是 TFFT + if (outcome === "success" && typeof request.tfftMs === "number") { + bucket.ttfbValues.push(request.tfftMs); } if (outcome === "success" && typeof tps === "number") { bucket.tpsValues.push(tps); @@ -455,7 +457,8 @@ export async function queryPublicStatusRequests(input: { model: messageRequest.model, originalModel: messageRequest.originalModel, durationMs: messageRequest.durationMs, - ttfbMs: messageRequest.ttfbMs, + tfftMs: messageRequest.tfftMs, + firstByteMs: messageRequest.firstByteMs, outputTokens: messageRequest.outputTokens, statusCode: messageRequest.statusCode, errorMessage: messageRequest.errorMessage, @@ -495,7 +498,8 @@ export async function queryPublicStatusRequests(input: { model: row.model, originalModel: row.originalModel, durationMs: row.durationMs, - ttfbMs: row.ttfbMs, + tfftMs: row.tfftMs, + firstByteMs: row.firstByteMs, outputTokens: row.outputTokens, providerChain: existingChain, }, diff --git a/src/lib/public-status/rollup-store.ts b/src/lib/public-status/rollup-store.ts index 275e15edc..3026afc2c 100644 --- a/src/lib/public-status/rollup-store.ts +++ b/src/lib/public-status/rollup-store.ts @@ -39,7 +39,8 @@ export interface PublicStatusRollupEvent { model?: string | null; originalModel?: string | null; durationMs?: number | null; - ttfbMs?: number | null; + tfftMs?: number | null; + firstByteMs?: number | null; outputTokens?: number | null; providerChain?: ProviderChainItem[] | null; } @@ -339,11 +340,12 @@ export function buildPublicStatusRollupIncrements(input: { } } - const ttfbMs = normalizeNumber(input.event.ttfbMs); + // ttfb_sum / ttfb_count 是既有 rollup 键名,存的是 TFFT(改名会作废已积累的桶) + const tfftMs = normalizeNumber(input.event.tfftMs); const tps = computeTokensPerSecond({ outputTokens: input.event.outputTokens, durationMs: input.event.durationMs, - ttfbMs, + firstByteMs: normalizeNumber(input.event.firstByteMs), }); const increments: PublicStatusRollupIncrement[] = []; @@ -367,9 +369,9 @@ export function buildPublicStatusRollupIncrements(input: { metric: outcome === "success" ? "success" : "failure", value: 1, }); - if (outcome === "success" && ttfbMs !== null) { + if (outcome === "success" && tfftMs !== null) { increments.push( - { groupId, modelKey, metric: "ttfb_sum", value: ttfbMs }, + { groupId, modelKey, metric: "ttfb_sum", value: tfftMs }, { groupId, modelKey, metric: "ttfb_count", value: 1 } ); } diff --git a/src/lib/utils/performance-formatter.test.ts b/src/lib/utils/performance-formatter.test.ts new file mode 100644 index 000000000..fb13f6c4e --- /dev/null +++ b/src/lib/utils/performance-formatter.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; +import { calculateOutputRate, shouldHideOutputRate } from "./performance-formatter"; + +describe("calculateOutputRate", () => { + it("以真 TTFB 为生成窗口起点", () => { + // 1000ms 总耗时,TTFB 500ms => 生成窗口 0.5s,50 tokens => 100 tok/s + expect(calculateOutputRate(50, 1000, 500)).toBe(100); + }); + + it("firstByteMs 缺失返回 null,不再回退到总耗时", () => { + // 门禁上线前的历史行只有 TFFT。用总耗时兜底会把上游排队算进生成时间。 + expect(calculateOutputRate(50, 1000, null)).toBeNull(); + }); + + it("TTFB 大于 TFFT 会让 TPS 偏高,TTFB 基准才是准确值", () => { + const basedOnTfft = calculateOutputRate(50, 1000, 900); + const basedOnTtfb = calculateOutputRate(50, 1000, 200); + + expect(basedOnTfft).toBe(500); + expect(basedOnTtfb).toBe(62.5); + expect(basedOnTtfb!).toBeLessThan(basedOnTfft!); + }); + + it("生成窗口非正、无 token、无耗时都返回 null", () => { + expect(calculateOutputRate(50, 1000, 1000)).toBeNull(); + expect(calculateOutputRate(50, 1000, 1200)).toBeNull(); + expect(calculateOutputRate(0, 1000, 100)).toBeNull(); + expect(calculateOutputRate(null, 1000, 100)).toBeNull(); + expect(calculateOutputRate(50, null, 100)).toBeNull(); + expect(calculateOutputRate(50, 0, 100)).toBeNull(); + }); +}); + +describe("shouldHideOutputRate", () => { + it("生成窗口占比 <10% 且速率 >5000 时隐藏", () => { + expect(shouldHideOutputRate(6000, 1000, 950)).toBe(true); + }); + + it("占比或速率任一不满足则不隐藏", () => { + expect(shouldHideOutputRate(100, 1000, 500)).toBe(false); + expect(shouldHideOutputRate(6000, 1000, 500)).toBe(false); + expect(shouldHideOutputRate(100, 1000, 950)).toBe(false); + }); + + it("缺少速率或 firstByteMs 时不隐藏(由 calculateOutputRate 决定是否展示)", () => { + expect(shouldHideOutputRate(null, 1000, 950)).toBe(false); + expect(shouldHideOutputRate(6000, 1000, null)).toBe(false); + expect(shouldHideOutputRate(Number.POSITIVE_INFINITY, 1000, 950)).toBe(false); + }); +}); diff --git a/src/lib/utils/performance-formatter.ts b/src/lib/utils/performance-formatter.ts index c5b89c9c7..28c29a830 100644 --- a/src/lib/utils/performance-formatter.ts +++ b/src/lib/utils/performance-formatter.ts @@ -44,16 +44,20 @@ export function formatDuration(durationMs: number | null): string { /** * 计算输出速率(tokens/second) + * + * 生成窗口以真 TTFB 为起点。firstByteMs 缺失(流式门禁上线前的历史行)返回 null, + * 不再退回总耗时——那会把上游排队和中性帧窗口算进生成时间,高估速率。 */ export function calculateOutputRate( outputTokens: number | null, durationMs: number | null, - ttfbMs: number | null + firstByteMs: number | null ): number | null { if (outputTokens == null || outputTokens <= 0 || durationMs == null || durationMs <= 0) { return null; } - const generationTimeMs = ttfbMs != null ? durationMs - ttfbMs : durationMs; + if (firstByteMs == null) return null; + const generationTimeMs = durationMs - firstByteMs; if (generationTimeMs <= 0) return null; return outputTokens / (generationTimeMs / 1000); } @@ -66,18 +70,18 @@ export function calculateOutputRate( export function shouldHideOutputRate( outputRate: number | null, durationMs: number | null, - ttfbMs: number | null + firstByteMs: number | null ): boolean { if ( outputRate == null || !Number.isFinite(outputRate) || durationMs == null || durationMs <= 0 || - ttfbMs == null + firstByteMs == null ) { return false; } - const generationTimeMs = durationMs - ttfbMs; + const generationTimeMs = durationMs - firstByteMs; if (generationTimeMs <= 0) return false; const ratio = generationTimeMs / durationMs; return ratio < 0.1 && outputRate > 5000; diff --git a/src/repository/leaderboard.ts b/src/repository/leaderboard.ts index 66fa27d18..7f404b38d 100644 --- a/src/repository/leaderboard.ts +++ b/src/repository/leaderboard.ts @@ -663,17 +663,19 @@ async function findProviderLeaderboardWithTimezone( 0::double precision )`; const successRateExpr = LEDGER_SUCCESS_RATE_EXPR; - const avgTtfbMsExpr = sql`COALESCE(avg(${usageLedger.ttfbMs})::double precision, 0::double precision)`; + // 展示用的均值走 ttfb_ms 列,该列存的是 TFFT(见 schema.ts) + const avgTtfbMsExpr = sql`COALESCE(avg(${usageLedger.tfftMs})::double precision, 0::double precision)`; + // TPS 必须以真 TTFB 为基准;first_byte_ms 为 NULL 的历史行由 IS NOT NULL 排除 const avgTokensPerSecondExpr = sql`COALESCE( avg( CASE WHEN ${usageLedger.outputTokens} > 0 AND ${usageLedger.durationMs} IS NOT NULL - AND ${usageLedger.ttfbMs} IS NOT NULL - AND ${usageLedger.ttfbMs} < ${usageLedger.durationMs} - AND (${usageLedger.durationMs} - ${usageLedger.ttfbMs}) >= 100 + AND ${usageLedger.firstByteMs} IS NOT NULL + AND ${usageLedger.firstByteMs} < ${usageLedger.durationMs} + AND (${usageLedger.durationMs} - ${usageLedger.firstByteMs}) >= 100 THEN (${usageLedger.outputTokens}::double precision) - / ((${usageLedger.durationMs} - ${usageLedger.ttfbMs}) / 1000.0) + / ((${usageLedger.durationMs} - ${usageLedger.firstByteMs}) / 1000.0) END )::double precision, 0::double precision diff --git a/src/repository/message-write-buffer.ts b/src/repository/message-write-buffer.ts index e16602b5d..2f517a31b 100644 --- a/src/repository/message-write-buffer.ts +++ b/src/repository/message-write-buffer.ts @@ -17,7 +17,8 @@ export type MessageRequestUpdatePatch = { statusCode?: number; inputTokens?: number; outputTokens?: number; - ttfbMs?: number | null; + tfftMs?: number | null; + firstByteMs?: number | null; cacheCreationInputTokens?: number; cacheReadInputTokens?: number; cacheCreation5mInputTokens?: number; @@ -267,7 +268,9 @@ const COLUMN_MAP: Record = { statusCode: "status_code", inputTokens: "input_tokens", outputTokens: "output_tokens", - ttfbMs: "ttfb_ms", + // ttfb_ms 是 TFFT 的历史列名,见 schema.ts 的说明 + tfftMs: "ttfb_ms", + firstByteMs: "first_byte_ms", cacheCreationInputTokens: "cache_creation_input_tokens", cacheReadInputTokens: "cache_read_input_tokens", cacheCreation5mInputTokens: "cache_creation_5m_input_tokens", diff --git a/src/repository/message.ts b/src/repository/message.ts index 41f0acbd3..fa0627b2a 100644 --- a/src/repository/message.ts +++ b/src/repository/message.ts @@ -44,7 +44,8 @@ type PublicStatusFinalDetails = { durationMs?: number; statusCode?: number; outputTokens?: number; - ttfbMs?: number | null; + tfftMs?: number | null; + firstByteMs?: number | null; providerChain?: CreateMessageRequestData["provider_chain"]; errorMessage?: string; model?: string; @@ -190,7 +191,8 @@ function queuePublicStatusRollupForFinalDetails( model: details.model ?? seed.model, originalModel: seed.originalModel, durationMs: seed.durationMs, - ttfbMs: details.ttfbMs, + tfftMs: details.tfftMs, + firstByteMs: details.firstByteMs, outputTokens: details.outputTokens, providerChain: details.providerChain, }, @@ -493,7 +495,8 @@ export type MessageRequestDetailsUpdate = { statusCode?: number; inputTokens?: number; outputTokens?: number; - ttfbMs?: number | null; + tfftMs?: number | null; + firstByteMs?: number | null; cacheCreationInputTokens?: number; cacheReadInputTokens?: number; cacheCreation5mInputTokens?: number; @@ -553,8 +556,11 @@ export async function updateMessageRequestDetails( if (details.outputTokens !== undefined) { updateData.outputTokens = details.outputTokens; } - if (details.ttfbMs !== undefined) { - updateData.ttfbMs = details.ttfbMs; + if (details.tfftMs !== undefined) { + updateData.tfftMs = details.tfftMs; + } + if (details.firstByteMs !== undefined) { + updateData.firstByteMs = details.firstByteMs; } if (details.cacheCreationInputTokens !== undefined) { updateData.cacheCreationInputTokens = details.cacheCreationInputTokens; @@ -835,7 +841,8 @@ export async function findMessageRequestById(id: number): Promise ({ costUsd: "0.000001", costMultiplier: null, durationMs: 10, - ttfbMs: 5, + tfftMs: 5, errorMessage: null, providerChain: null, blockedBy: null, diff --git a/tests/unit/dashboard-logs-warmup-ui.test.tsx b/tests/unit/dashboard-logs-warmup-ui.test.tsx index 19b552de0..1f78f16a3 100644 --- a/tests/unit/dashboard-logs-warmup-ui.test.tsx +++ b/tests/unit/dashboard-logs-warmup-ui.test.tsx @@ -75,7 +75,7 @@ describe("UsageLogsTable - warmup 跳过展示", () => { costUsd: null, costMultiplier: null, durationMs: 0, - ttfbMs: 0, + tfftMs: 0, errorMessage: null, providerChain: null, blockedBy: "warmup", @@ -128,7 +128,7 @@ describe("UsageLogsTable - cache badge alignment", () => { costUsd: "0.000001", costMultiplier: null, durationMs: 10, - ttfbMs: 5, + tfftMs: 5, errorMessage: null, providerChain: null, blockedBy: null, diff --git a/tests/unit/error-details-dialog-warmup-ui.test.tsx b/tests/unit/error-details-dialog-warmup-ui.test.tsx index af96b9a8c..eacb43e36 100644 --- a/tests/unit/error-details-dialog-warmup-ui.test.tsx +++ b/tests/unit/error-details-dialog-warmup-ui.test.tsx @@ -164,7 +164,7 @@ describe("ErrorDetailsDialog - warmup skip indicator", () => { costMultiplier={null} context1mApplied={false} durationMs={null} - ttfbMs={null} + tfftMs={null} externalOpen /> ); diff --git a/tests/unit/langfuse/langfuse-trace.test.ts b/tests/unit/langfuse/langfuse-trace.test.ts index 92dcea2f8..0c4bd8262 100644 --- a/tests/unit/langfuse/langfuse-trace.test.ts +++ b/tests/unit/langfuse/langfuse-trace.test.ts @@ -100,7 +100,8 @@ function createMockSession(overrides: Record = {}) { user: { id: 7, name: "testuser" }, key: { name: "default-key" }, }, - ttfbMs: 200, + tfftMs: 200, + firstByteMs: 200, forwardStartTime: startTime + 5, forwardedRequestBody: null, getEndpoint: () => "/v1/messages", @@ -463,12 +464,12 @@ describe("traceProxyRequest", () => { expect(llmCall[1].metadata.originalModel).toBe("claude-sonnet-4-20250514"); }); - test("should set completionStartTime from ttfbMs", async () => { + test("should set completionStartTime from tfftMs", async () => { const { traceProxyRequest } = await import("@/lib/langfuse/trace-proxy-request"); const startTime = Date.now() - 500; await traceProxyRequest({ - session: createMockSession({ startTime, ttfbMs: 200 }), + session: createMockSession({ startTime, tfftMs: 200 }), responseHeaders: new Headers(), durationMs: 500, statusCode: 200, @@ -889,7 +890,8 @@ describe("traceProxyRequest", () => { session: createMockSession({ startTime, forwardStartTime, - ttfbMs: 105, + tfftMs: 105, + firstByteMs: 105, getProviderChain: () => [ { id: 1, name: "p1", reason: "retry_failed", timestamp: startTime + 50 }, { id: 2, name: "p2", reason: "request_success", timestamp: startTime + 100 }, @@ -904,8 +906,8 @@ describe("traceProxyRequest", () => { const expectedTimingBreakdown = { guardPipelineMs: 5, upstreamTotalMs: 495, - ttfbFromForwardMs: 100, // ttfbMs(105) - guardPipelineMs(5) - tokenGenerationMs: 395, // durationMs(500) - ttfbMs(105) + tfftFromForwardMs: 100, // tfftMs(105) - guardPipelineMs(5) + tokenGenerationMs: 395, // durationMs(500) - tfftMs(105) failedAttempts: 1, // only retry_failed is non-success providersAttempted: 2, // 2 unique provider ids }; diff --git a/tests/unit/proxy/response-handler-abort-listener-cleanup.test.ts b/tests/unit/proxy/response-handler-abort-listener-cleanup.test.ts index 02d7446d1..69ecff895 100644 --- a/tests/unit/proxy/response-handler-abort-listener-cleanup.test.ts +++ b/tests/unit/proxy/response-handler-abort-listener-cleanup.test.ts @@ -211,8 +211,9 @@ function makeSession(clientAbortSignal: AbortSignal | null, stream: boolean): Pr shouldPersistSessionDebugArtifacts: () => false, shouldTrackSessionObservability: () => false, getResolvedPricingByBillingSource: async () => null, - recordTtfb: vi.fn(), - ttfbMs: null, + recordTfft: vi.fn(), + tfftMs: null, + firstByteMs: null, addProviderToChain: vi.fn(), clearResponseTimeout: vi.fn(), releaseAgent: vi.fn(), diff --git a/tests/unit/proxy/response-handler-client-abort-drain.test.ts b/tests/unit/proxy/response-handler-client-abort-drain.test.ts index 9d0b093ce..71a03eb45 100644 --- a/tests/unit/proxy/response-handler-client-abort-drain.test.ts +++ b/tests/unit/proxy/response-handler-client-abort-drain.test.ts @@ -296,7 +296,8 @@ function createSession( sessionId: null, specialSettings: [], startTime: Date.now(), - ttfbMs: null, + tfftMs: null, + firstByteMs: null, userAgent: "Go-http-client/1.1", userName: "admin", addProviderToChain(this: ProxySession & { providerChain: unknown[] }, prov: Provider, meta) { @@ -317,7 +318,7 @@ function createSession( getResolvedPricingByBillingSource: async () => null, getSpecialSettings: () => [], isHeaderModified: () => false, - recordTtfb: vi.fn(), + recordTfft: vi.fn(), releaseAgent: vi.fn(), setContext1mApplied: vi.fn(), shouldPersistSessionDebugArtifacts: () => false, @@ -1422,7 +1423,7 @@ describe("ProxyResponseHandler stream client abort finalization", () => { await downstream.text(); await drainAsyncTasks(); - expect(session.recordTtfb).not.toHaveBeenCalled(); + expect(session.recordTfft).not.toHaveBeenCalled(); expect(session.clearResponseTimeout).toHaveBeenCalledTimes(1); }); @@ -2116,67 +2117,65 @@ describe("ProxyResponseHandler stream client abort finalization", () => { it.each([ { bindingIntent: "create" as const, providerId: null }, { bindingIntent: "renew" as const, providerId: 1 }, - ])( - "preserves binding state for a client-aborted Discovery $bindingIntent stream", - async ({ bindingIntent, providerId }) => { - const controller = new AbortController(); - controller.abort(); - const session = createSession(controller.signal); - Object.assign(session, { + ])("preserves binding state for a client-aborted Discovery $bindingIntent stream", async ({ + bindingIntent, + providerId, + }) => { + const controller = new AbortController(); + controller.abort(); + const session = createSession(controller.signal); + Object.assign(session, { + sessionId: `session-client-abort-${bindingIntent}`, + }); + session.recordProviderSessionRef(1); + vi.mocked(SessionManager.extractCodexPromptCacheKey).mockReturnValue("client-abort-cache-key"); + setDeferredStreamingFinalization(session, { + providerId: 1, + providerName: "avemujica-responses", + providerPriority: 1, + attemptNumber: 1, + totalProvidersAttempted: 2, + isFirstAttempt: false, + isFailoverSuccess: bindingIntent === "create", + endpointId: 42, + endpointUrl: "https://api.test.invalid/v1", + upstreamStatusCode: 200, + bindingIntent, + bindingSnapshot: { sessionId: `session-client-abort-${bindingIntent}`, - }); - session.recordProviderSessionRef(1); - vi.mocked(SessionManager.extractCodexPromptCacheKey).mockReturnValue( - "client-abort-cache-key" - ); - setDeferredStreamingFinalization(session, { - providerId: 1, - providerName: "avemujica-responses", - providerPriority: 1, - attemptNumber: 1, - totalProvidersAttempted: 2, - isFirstAttempt: false, - isFailoverSuccess: bindingIntent === "create", - endpointId: 42, - endpointUrl: "https://api.test.invalid/v1", - upstreamStatusCode: 200, - bindingIntent, - bindingSnapshot: { - sessionId: `session-client-abort-${bindingIntent}`, - keyId: 2, - providerId, - generation: `${bindingIntent}-generation`, - }, - requiresCompletionMarkerForBinding: true, - discoveryLease: { - sessionId: `session-client-abort-${bindingIntent}`, - keyId: 2, - ownerToken: `client-abort-${bindingIntent}-owner`, - ttlSeconds: 30, - }, - providerSessionRefOwned: true, - }); + keyId: 2, + providerId, + generation: `${bindingIntent}-generation`, + }, + requiresCompletionMarkerForBinding: true, + discoveryLease: { + sessionId: `session-client-abort-${bindingIntent}`, + keyId: 2, + ownerToken: `client-abort-${bindingIntent}-owner`, + ttlSeconds: 30, + }, + providerSessionRefOwned: true, + }); - await ProxyResponseHandler.dispatch(session, createCompletedThenErroredResponsesSse()); - await drainAsyncTasks(); + await ProxyResponseHandler.dispatch(session, createCompletedThenErroredResponsesSse()); + await drainAsyncTasks(); - expect(SessionManager.clearVersionedSessionProvider).not.toHaveBeenCalled(); - expect(SessionManager.clearSessionProvider).not.toHaveBeenCalled(); - expect(SessionManager.compareAndSetSessionProvider).not.toHaveBeenCalled(); - expect(SessionManager.updateSessionBindingSmart).not.toHaveBeenCalled(); - expect(SessionManager.updateSessionWithCodexCacheKey).not.toHaveBeenCalled(); - expect(SessionManager.releaseSessionDiscoveryLease).toHaveBeenCalledOnce(); - expect(SessionManager.releaseSessionDiscoveryLease).toHaveBeenCalledWith( - `session-client-abort-${bindingIntent}`, - 2, - `client-abort-${bindingIntent}-owner` - ); - expect(RateLimitService.releaseProviderSession).toHaveBeenCalledWith( - 1, - `session-client-abort-${bindingIntent}` - ); - } - ); + expect(SessionManager.clearVersionedSessionProvider).not.toHaveBeenCalled(); + expect(SessionManager.clearSessionProvider).not.toHaveBeenCalled(); + expect(SessionManager.compareAndSetSessionProvider).not.toHaveBeenCalled(); + expect(SessionManager.updateSessionBindingSmart).not.toHaveBeenCalled(); + expect(SessionManager.updateSessionWithCodexCacheKey).not.toHaveBeenCalled(); + expect(SessionManager.releaseSessionDiscoveryLease).toHaveBeenCalledOnce(); + expect(SessionManager.releaseSessionDiscoveryLease).toHaveBeenCalledWith( + `session-client-abort-${bindingIntent}`, + 2, + `client-abort-${bindingIntent}-owner` + ); + expect(RateLimitService.releaseProviderSession).toHaveBeenCalledWith( + 1, + `session-client-abort-${bindingIntent}` + ); + }); it("keeps a genuinely aborted upstream responses stream as 499", async () => { const controller = new AbortController(); @@ -3038,55 +3037,52 @@ describe("ProxyResponseHandler stream client abort finalization", () => { it.each([ ["response timeout", "timeout"], ["client abort", "client"], - ] as const)( - "uses the conditional fallback when the non-stream %s finalizer durable write rejects", - async (_name, abortSource) => { - vi.mocked(updateMessageRequestDetailsDurably).mockRejectedValueOnce( - new Error("durable finalizer acknowledgement failed") - ); - const clientController = new AbortController(); - const responseController = new AbortController(); - const session = createSession(clientController.signal); - Object.assign(session, { responseController }); - const response = createAbortableNonStreamResponse( - abortSource === "timeout" ? responseController.signal : clientController.signal - ); - - await ProxyResponseHandler.dispatch(session, response); - const abortError = new Error(`non-stream ${abortSource}`); - abortError.name = "AbortError"; - if (abortSource === "timeout") { - responseController.abort(abortError); - } else { - clientController.abort(abortError); - } - await drainAsyncTasks(); + ] as const)("uses the conditional fallback when the non-stream %s finalizer durable write rejects", async (_name, abortSource) => { + vi.mocked(updateMessageRequestDetailsDurably).mockRejectedValueOnce( + new Error("durable finalizer acknowledgement failed") + ); + const clientController = new AbortController(); + const responseController = new AbortController(); + const session = createSession(clientController.signal); + Object.assign(session, { responseController }); + const response = createAbortableNonStreamResponse( + abortSource === "timeout" ? responseController.signal : clientController.signal + ); - expect(updateMessageRequestDetails).not.toHaveBeenCalled(); - expect(updateMessageRequestDetailsIfUnfinalized).toHaveBeenCalledTimes(1); - expect(updateMessageRequestDetailsIfUnfinalized).toHaveBeenCalledWith( - 123, - expect.objectContaining({ - statusCode: abortSource === "timeout" ? 502 : 499, - ...(abortSource === "timeout" - ? { errorMessage: expect.stringContaining("non-stream timeout") } - : {}), - providerId: 1, - providerChain: - abortSource === "timeout" - ? [ - expect.objectContaining({ - id: 1, - statusCode: 502, - errorMessage: expect.stringContaining("non-stream timeout"), - }), - ] - : [], - }), - expect.objectContaining({ onCommitted: expect.any(Function) }) - ); + await ProxyResponseHandler.dispatch(session, response); + const abortError = new Error(`non-stream ${abortSource}`); + abortError.name = "AbortError"; + if (abortSource === "timeout") { + responseController.abort(abortError); + } else { + clientController.abort(abortError); } - ); + await drainAsyncTasks(); + + expect(updateMessageRequestDetails).not.toHaveBeenCalled(); + expect(updateMessageRequestDetailsIfUnfinalized).toHaveBeenCalledTimes(1); + expect(updateMessageRequestDetailsIfUnfinalized).toHaveBeenCalledWith( + 123, + expect.objectContaining({ + statusCode: abortSource === "timeout" ? 502 : 499, + ...(abortSource === "timeout" + ? { errorMessage: expect.stringContaining("non-stream timeout") } + : {}), + providerId: 1, + providerChain: + abortSource === "timeout" + ? [ + expect.objectContaining({ + id: 1, + statusCode: 502, + errorMessage: expect.stringContaining("non-stream timeout"), + }), + ] + : [], + }), + expect.objectContaining({ onCommitted: expect.any(Function) }) + ); + }); it("rejects non-stream processing when both terminal persistence attempts fail", async () => { vi.mocked(updateMessageRequestDetailsDurably).mockRejectedValueOnce( @@ -3168,28 +3164,25 @@ describe("ProxyResponseHandler stream client abort finalization", () => { model: "gemini-2.0-flash", }, ], - ] as const)( - "keeps non-stream 404 out of the Provider circuit for %s responses", - async (_name, overrides) => { - const session = createSession(new AbortController().signal, overrides); - const response = new Response('{"error":{"message":"model not found"}}', { - status: 404, - headers: { "content-type": "application/json" }, - }); + ] as const)("keeps non-stream 404 out of the Provider circuit for %s responses", async (_name, overrides) => { + const session = createSession(new AbortController().signal, overrides); + const response = new Response('{"error":{"message":"model not found"}}', { + status: 404, + headers: { "content-type": "application/json" }, + }); - await ProxyResponseHandler.dispatch(session, response); - await drainAsyncTasks(); + await ProxyResponseHandler.dispatch(session, response); + await drainAsyncTasks(); - expect(recordFailure).not.toHaveBeenCalled(); - expect(session.getProviderChain()).toEqual([ - expect.objectContaining({ - id: 1, - reason: "resource_not_found", - statusCode: 404, - }), - ]); - } - ); + expect(recordFailure).not.toHaveBeenCalled(); + expect(session.getProviderChain()).toEqual([ + expect.objectContaining({ + id: 1, + reason: "resource_not_found", + statusCode: 404, + }), + ]); + }); it("persists Gemini non-stream duration atomically with terminal stats", async () => { const session = createSession(new AbortController().signal, { diff --git a/tests/unit/proxy/response-handler-endpoint-circuit-isolation.test.ts b/tests/unit/proxy/response-handler-endpoint-circuit-isolation.test.ts index e85277206..8af778a50 100644 --- a/tests/unit/proxy/response-handler-endpoint-circuit-isolation.test.ts +++ b/tests/unit/proxy/response-handler-endpoint-circuit-isolation.test.ts @@ -232,8 +232,9 @@ function createSession(opts?: { sessionId?: string | null }): ProxySession { getCurrentModel: () => "test-model", getProviderChain: () => session.providerChain, getCachedPriceDataByBillingSource: async () => testPriceData, - recordTtfb: () => 100, - ttfbMs: null, + recordTfft: () => 100, + tfftMs: null, + firstByteMs: null, getRequestSequence: () => 1, addProviderToChain: function ( this: ProxySession & { providerChain: Record[] }, @@ -1025,54 +1026,54 @@ describe("Endpoint circuit breaker isolation", () => { format: "gemini" as const, body: `${JSON.stringify({ candidates: [{ content: { parts: [{ text: "ok" }] } }] })}\n`, }, - ])( - "keeps a naturally completed $label stream successful but unbound without a marker", - async ({ format, body }) => { - const session = createSession(); - session.originalFormat = format; - if (format === "gemini" || format === "gemini-cli") { - session.provider = { ...session.provider!, providerType: format }; - } - const snapshot = { - sessionId: "fake-session", - keyId: 456, - providerId: null, - generation: `${format}-natural-eof-generation`, - } as const; - setDeferredStreamingFinalization(session, { - providerId: 1, - providerName: "test-provider", - providerPriority: 10, - attemptNumber: 1, - totalProvidersAttempted: 2, - isFirstAttempt: false, - isFailoverSuccess: true, - endpointId: 42, - endpointUrl: "https://api.test.com", - upstreamStatusCode: 200, - bindingIntent: "create", - bindingSnapshot: snapshot, - requiresCompletionMarkerForBinding: true, - }); + ])("keeps a naturally completed $label stream successful but unbound without a marker", async ({ + format, + body, + }) => { + const session = createSession(); + session.originalFormat = format; + if (format === "gemini" || format === "gemini-cli") { + session.provider = { ...session.provider!, providerType: format }; + } + const snapshot = { + sessionId: "fake-session", + keyId: 456, + providerId: null, + generation: `${format}-natural-eof-generation`, + } as const; + setDeferredStreamingFinalization(session, { + providerId: 1, + providerName: "test-provider", + providerPriority: 10, + attemptNumber: 1, + totalProvidersAttempted: 2, + isFirstAttempt: false, + isFailoverSuccess: true, + endpointId: 42, + endpointUrl: "https://api.test.com", + upstreamStatusCode: 200, + bindingIntent: "create", + bindingSnapshot: snapshot, + requiresCompletionMarkerForBinding: true, + }); - const clientResponse = await ProxyResponseHandler.dispatch( - session, - new Response(body, { - status: 200, - headers: { "content-type": "text/event-stream" }, - }) - ); - await expect(clientResponse.text()).resolves.toContain("ok"); - await drainAsyncTasks(); + const clientResponse = await ProxyResponseHandler.dispatch( + session, + new Response(body, { + status: 200, + headers: { "content-type": "text/event-stream" }, + }) + ); + await expect(clientResponse.text()).resolves.toContain("ok"); + await drainAsyncTasks(); - expect(SessionManager.compareAndSetSessionProvider).not.toHaveBeenCalled(); - expect(mockRecordFailure).not.toHaveBeenCalled(); - expect(mockRecordSuccess).toHaveBeenCalledWith(1); - const details = vi.mocked(updateMessageRequestDetailsDurably).mock.calls.at(-1)?.[1]; - expect(details).toEqual(expect.objectContaining({ statusCode: 200 })); - expect(details).not.toHaveProperty("errorMessage"); - } - ); + expect(SessionManager.compareAndSetSessionProvider).not.toHaveBeenCalled(); + expect(mockRecordFailure).not.toHaveBeenCalled(); + expect(mockRecordSuccess).toHaveBeenCalledWith(1); + const details = vi.mocked(updateMessageRequestDetailsDurably).mock.calls.at(-1)?.[1]; + expect(details).toEqual(expect.objectContaining({ statusCode: 200 })); + expect(details).not.toHaveProperty("errorMessage"); + }); it("does not accept completion marker words embedded in ordinary SSE content", async () => { const session = createSession(); diff --git a/tests/unit/proxy/response-handler-lease-decrement.test.ts b/tests/unit/proxy/response-handler-lease-decrement.test.ts index dd76fdc05..9d542e8c4 100644 --- a/tests/unit/proxy/response-handler-lease-decrement.test.ts +++ b/tests/unit/proxy/response-handler-lease-decrement.test.ts @@ -210,8 +210,9 @@ function createSession(opts: { source: "cloud_exact" as const, priceData: testPriceData, }), - recordTtfb: () => 100, - ttfbMs: null, + recordTfft: () => 100, + tfftMs: null, + firstByteMs: null, getRequestSequence: () => 1, }); diff --git a/tests/unit/proxy/response-handler-non200.test.ts b/tests/unit/proxy/response-handler-non200.test.ts index 6a665d599..d7160ee19 100644 --- a/tests/unit/proxy/response-handler-non200.test.ts +++ b/tests/unit/proxy/response-handler-non200.test.ts @@ -194,8 +194,9 @@ function createSession(opts: { getCurrentModel: () => redirectedModel, getProviderChain: () => session.providerChain, getCachedPriceDataByBillingSource: async () => testPriceData, - recordTtfb: () => 100, - ttfbMs: null, + recordTfft: () => 100, + tfftMs: null, + firstByteMs: null, getRequestSequence: () => 1, addProviderToChain: function ( prov: Provider, diff --git a/tests/unit/proxy/session-ttfb-tfft.test.ts b/tests/unit/proxy/session-ttfb-tfft.test.ts new file mode 100644 index 000000000..798557ec0 --- /dev/null +++ b/tests/unit/proxy/session-ttfb-tfft.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it, vi } from "vitest"; + +vi.mock("@/repository/model-price", () => ({ + findLatestPriceByModel: vi.fn(), +})); + +vi.mock("@/repository/system-config", () => ({ + getSystemSettings: vi.fn(), +})); + +import { ProxySession } from "@/app/v1/_lib/proxy/session"; + +function createSession(startTime: number): ProxySession { + return new ( + ProxySession as unknown as { + new (init: { + startTime: number; + method: string; + requestUrl: URL; + headers: Headers; + headerLog: string; + request: { message: Record; log: string; model: string | null }; + userAgent: string | null; + context: unknown; + clientAbortSignal: AbortSignal | null; + }): ProxySession; + } + )({ + startTime, + method: "POST", + requestUrl: new URL("http://localhost/v1/messages"), + headers: new Headers(), + headerLog: "", + request: { message: {}, log: "(test)", model: null }, + userAgent: null, + context: {}, + clientAbortSignal: null, + }); +} + +describe("ProxySession TTFB / TFFT", () => { + it("门控旁路时 recordTfft 同时补齐 TTFB(两者同一时刻)", () => { + const session = createSession(Date.now() - 1_200); + + const tfft = session.recordTfft(); + + expect(session.tfftMs).toBe(tfft); + expect(session.firstByteMs).toBe(tfft); + }); + + it("门控提交时先记 TTFB,recordTfft 不覆盖它", () => { + const startTime = Date.now() - 3_000; + const session = createSession(startTime); + + session.recordFirstByte(startTime + 400); + const tfft = session.recordTfft(); + + expect(session.firstByteMs).toBe(400); + expect(session.tfftMs).toBe(tfft); + // TTFB 必须早于 TFFT,否则延迟分解与 TPS 分母都会失真 + expect(session.firstByteMs!).toBeLessThan(session.tfftMs!); + }); + + it("recordFirstByte 首写生效:failover 后不会被后续尝试改写", () => { + const startTime = Date.now() - 5_000; + const session = createSession(startTime); + + session.recordFirstByte(startTime + 900); + session.recordFirstByte(startTime + 2_500); + + expect(session.firstByteMs).toBe(900); + }); + + it("recordFirstByte 对早于 startTime 的时刻钳到 0", () => { + const startTime = Date.now(); + const session = createSession(startTime); + + session.recordFirstByte(startTime - 50); + + expect(session.firstByteMs).toBe(0); + }); + + it("recordTfft 幂等:重复调用不改变已记录的值", () => { + const session = createSession(Date.now() - 800); + + const first = session.recordTfft(); + const second = session.recordTfft(); + + expect(second).toBe(first); + expect(session.firstByteMs).toBe(first); + }); +}); diff --git a/tests/unit/public-status/aggregation-core-tps.test.ts b/tests/unit/public-status/aggregation-core-tps.test.ts new file mode 100644 index 000000000..b2e2f2f23 --- /dev/null +++ b/tests/unit/public-status/aggregation-core-tps.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; +import { computeTokensPerSecond } from "@/lib/public-status/aggregation-core"; + +describe("computeTokensPerSecond", () => { + it("以真 TTFB 为生成窗口起点", () => { + expect(computeTokensPerSecond({ outputTokens: 50, durationMs: 1000, firstByteMs: 500 })).toBe( + 100 + ); + }); + + it("firstByteMs 缺失返回 null(门禁上线前的历史行不参与 TPS)", () => { + expect( + computeTokensPerSecond({ outputTokens: 50, durationMs: 1000, firstByteMs: null }) + ).toBeNull(); + expect(computeTokensPerSecond({ outputTokens: 50, durationMs: 1000 })).toBeNull(); + }); + + it("TTFB 基准得到的 TPS 低于(被门控放大的)TFFT 基准", () => { + const basedOnTfft = computeTokensPerSecond({ + outputTokens: 50, + durationMs: 1000, + firstByteMs: 900, + }); + const basedOnTtfb = computeTokensPerSecond({ + outputTokens: 50, + durationMs: 1000, + firstByteMs: 200, + }); + + expect(basedOnTfft).toBe(500); + expect(basedOnTtfb).toBe(62.5); + }); + + it("生成窗口非正、无 token、无耗时都返回 null", () => { + expect( + computeTokensPerSecond({ outputTokens: 50, durationMs: 1000, firstByteMs: 1000 }) + ).toBeNull(); + expect( + computeTokensPerSecond({ outputTokens: 0, durationMs: 1000, firstByteMs: 100 }) + ).toBeNull(); + expect( + computeTokensPerSecond({ outputTokens: 50, durationMs: null, firstByteMs: 100 }) + ).toBeNull(); + }); +}); diff --git a/tests/unit/public-status/aggregation.test.ts b/tests/unit/public-status/aggregation.test.ts index ff10ffa4e..7d19ed7e4 100644 --- a/tests/unit/public-status/aggregation.test.ts +++ b/tests/unit/public-status/aggregation.test.ts @@ -34,7 +34,8 @@ describe("public-status aggregation", () => { createdAt: "2026-04-21T10:10:00.000Z", originalModel: "gpt-4.1", durationMs: 1000, - ttfbMs: 200, + tfftMs: 200, + firstByteMs: 200, outputTokens: 80, providerChain: [ { @@ -51,7 +52,8 @@ describe("public-status aggregation", () => { createdAt: "2026-04-21T10:40:00.000Z", originalModel: "gpt-4.1", durationMs: 1400, - ttfbMs: 300, + tfftMs: 300, + firstByteMs: 300, outputTokens: 60, providerChain: [ { @@ -101,7 +103,8 @@ describe("public-status aggregation", () => { createdAt: "2026-04-21T10:25:00.000Z", originalModel: "gpt-4.1", durationMs: 1500, - ttfbMs: 500, + tfftMs: 500, + firstByteMs: 500, outputTokens: null, providerChain: [ { @@ -226,7 +229,8 @@ describe("public-status aggregation", () => { createdAt: "2026-04-21T10:10:00.000Z", originalModel: "gpt-4.1", durationMs: 1200, - ttfbMs: 200, + tfftMs: 200, + firstByteMs: 200, outputTokens: 50, providerChain: [ { diff --git a/tests/unit/public-status/rollup-store.test.ts b/tests/unit/public-status/rollup-store.test.ts index 44c9f7a05..466f9207d 100644 --- a/tests/unit/public-status/rollup-store.test.ts +++ b/tests/unit/public-status/rollup-store.test.ts @@ -42,7 +42,8 @@ describe("public-status rollup store", () => { createdAt: "2026-04-21T10:02:00.000Z", originalModel: "gpt-4.1", durationMs: 1200, - ttfbMs: 200, + tfftMs: 200, + firstByteMs: 200, outputTokens: 50, providerChain: [ { @@ -149,7 +150,8 @@ describe("public-status rollup store", () => { createdAt: "2026-04-21T10:02:00.000Z", originalModel: "gpt-4.1", durationMs: 1200, - ttfbMs: 200, + tfftMs: 200, + firstByteMs: 200, outputTokens: 50, providerChain: [ { @@ -225,7 +227,8 @@ describe("public-status rollup store", () => { createdAt: "2026-04-21T10:02:00.000Z", originalModel: "gpt-4.1", durationMs: 1200, - ttfbMs: 200, + tfftMs: 200, + firstByteMs: 200, outputTokens: 50, providerChain: [ { @@ -309,7 +312,8 @@ describe("public-status rollup store", () => { createdAt: "2026-04-21T10:02:00.000Z", originalModel: "gpt-4.1", durationMs: 1200, - ttfbMs: 200, + tfftMs: 200, + firstByteMs: 200, outputTokens: 50, providerChain: [ { @@ -346,7 +350,8 @@ describe("public-status rollup store", () => { createdAt: "2026-04-21T10:02:00.000Z", originalModel: "gpt-4.1", durationMs: 1200, - ttfbMs: 200, + tfftMs: 200, + firstByteMs: 200, outputTokens: 50, providerChain: [ { diff --git a/tests/unit/repository/leaderboard-provider-metrics.test.ts b/tests/unit/repository/leaderboard-provider-metrics.test.ts index 102dbf0f2..ac85f9ae8 100644 --- a/tests/unit/repository/leaderboard-provider-metrics.test.ts +++ b/tests/unit/repository/leaderboard-provider-metrics.test.ts @@ -53,7 +53,8 @@ vi.mock("@/drizzle/schema", () => ({ successRateOutcome: "successRateOutcome", blockedBy: "blockedBy", createdAt: "createdAt", - ttfbMs: "ttfbMs", + tfftMs: "tfftMs", + firstByteMs: "firstByteMs", durationMs: "durationMs", model: "model", originalModel: "originalModel", @@ -70,7 +71,8 @@ vi.mock("@/drizzle/schema", () => ({ errorMessage: "errorMessage", blockedBy: "blockedBy", createdAt: "createdAt", - ttfbMs: "ttfbMs", + tfftMs: "tfftMs", + firstByteMs: "firstByteMs", durationMs: "durationMs", model: "model", originalModel: "originalModel", diff --git a/tests/unit/repository/leaderboard-timezone-parentheses.test.ts b/tests/unit/repository/leaderboard-timezone-parentheses.test.ts index 8eafff952..7cd5195ec 100644 --- a/tests/unit/repository/leaderboard-timezone-parentheses.test.ts +++ b/tests/unit/repository/leaderboard-timezone-parentheses.test.ts @@ -87,7 +87,8 @@ vi.mock("@/drizzle/schema", () => ({ cacheReadInputTokens: "cacheReadInputTokens", blockedBy: "blockedBy", createdAt: "createdAt", - ttfbMs: "ttfbMs", + tfftMs: "tfftMs", + firstByteMs: "firstByteMs", durationMs: "durationMs", statusCode: "statusCode", isSuccess: "isSuccess", @@ -107,7 +108,8 @@ vi.mock("@/drizzle/schema", () => ({ errorMessage: "errorMessage", blockedBy: "blockedBy", createdAt: "createdAt", - ttfbMs: "ttfbMs", + tfftMs: "tfftMs", + firstByteMs: "firstByteMs", durationMs: "durationMs", statusCode: "statusCode", model: "model", diff --git a/tests/unit/repository/leaderboard-tps-basis.test.ts b/tests/unit/repository/leaderboard-tps-basis.test.ts new file mode 100644 index 000000000..9b2f0167a --- /dev/null +++ b/tests/unit/repository/leaderboard-tps-basis.test.ts @@ -0,0 +1,100 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +/** + * 排行榜的两个延迟指标口径不同,必须分开: + * - 展示用的 avgTtfbMs 走 usage_ledger.ttfb_ms(该列存的是 TFFT) + * - avgTokensPerSecond 的分母必须是真 TTFB(first_byte_ms),历史行由 IS NOT NULL 排除 + */ + +const createChainMock = (resolvedData: unknown[]) => ({ + from: vi.fn().mockReturnThis(), + innerJoin: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + groupBy: vi.fn().mockReturnThis(), + orderBy: vi.fn().mockResolvedValue(resolvedData), +}); + +let selectedProjections: unknown[] = []; +const mockSelect = vi.fn((projection: unknown) => { + selectedProjections.push(projection); + return createChainMock([]); +}); + +const mocks = vi.hoisted(() => ({ + resolveSystemTimezone: vi.fn(), + getSystemSettings: vi.fn(), + getProviderCacheCoefficients: vi.fn(), +})); + +vi.mock("@/drizzle/db", () => ({ + db: { + select: (...args: unknown[]) => mockSelect(args[0]), + }, +})); + +vi.mock("@/drizzle/schema", () => ({ + usageLedger: { + providerId: "providerId", + finalProviderId: "finalProviderId", + userId: "userId", + costUsd: "costUsd", + inputTokens: "inputTokens", + outputTokens: "outputTokens", + cacheCreationInputTokens: "cacheCreationInputTokens", + cacheReadInputTokens: "cacheReadInputTokens", + isSuccess: "isSuccess", + successRateOutcome: "successRateOutcome", + blockedBy: "blockedBy", + createdAt: "createdAt", + tfftMs: "tfftMs", + firstByteMs: "firstByteMs", + durationMs: "durationMs", + model: "model", + originalModel: "originalModel", + }, + providers: { id: "id", name: "name" }, + users: { id: "id", name: "name" }, + messageRequest: {}, +})); + +vi.mock("@/lib/utils/timezone", () => ({ + resolveSystemTimezone: mocks.resolveSystemTimezone, +})); + +vi.mock("@/repository/system-config", () => ({ + getSystemSettings: mocks.getSystemSettings, +})); + +vi.mock("@/repository/provider-cache-effectiveness", () => ({ + getProviderCacheCoefficients: mocks.getProviderCacheCoefficients, + resolveLeaderboardWindow: () => ({ start: new Date(0), end: new Date() }), +})); + +beforeEach(() => { + vi.clearAllMocks(); + selectedProjections = []; + mocks.resolveSystemTimezone.mockResolvedValue("UTC"); + mocks.getSystemSettings.mockResolvedValue({ timezone: "UTC" }); + mocks.getProviderCacheCoefficients.mockResolvedValue(new Map()); +}); + +describe("排行榜延迟指标口径", () => { + it("TPS 分母用 first_byte_ms,展示均值仍用 ttfb_ms 列", async () => { + const { findDailyProviderLeaderboard } = await import("@/repository/leaderboard"); + await findDailyProviderLeaderboard(); + + const projection = selectedProjections.find( + (item): item is Record => + typeof item === "object" && item !== null && "avgTokensPerSecond" in item + ); + expect(projection).toBeDefined(); + + const tpsSql = JSON.stringify(projection?.avgTokensPerSecond); + expect(tpsSql).toContain("firstByteMs"); + expect(tpsSql).not.toContain("tfftMs"); + + const avgLatencySql = JSON.stringify(projection?.avgTtfbMs); + expect(avgLatencySql).toContain("tfftMs"); + expect(avgLatencySql).not.toContain("firstByteMs"); + }); +}); diff --git a/tests/unit/repository/leaderboard-user-model-stats.test.ts b/tests/unit/repository/leaderboard-user-model-stats.test.ts index 9049cd32c..6e75b17e1 100644 --- a/tests/unit/repository/leaderboard-user-model-stats.test.ts +++ b/tests/unit/repository/leaderboard-user-model-stats.test.ts @@ -83,7 +83,8 @@ vi.mock("@/drizzle/schema", () => ({ successRateOutcome: "successRateOutcome", blockedBy: "blockedBy", createdAt: "createdAt", - ttfbMs: "ttfbMs", + tfftMs: "tfftMs", + firstByteMs: "firstByteMs", durationMs: "durationMs", model: "model", originalModel: "originalModel", @@ -100,7 +101,8 @@ vi.mock("@/drizzle/schema", () => ({ errorMessage: "errorMessage", blockedBy: "blockedBy", createdAt: "createdAt", - ttfbMs: "ttfbMs", + tfftMs: "tfftMs", + firstByteMs: "firstByteMs", durationMs: "durationMs", model: "model", originalModel: "originalModel", diff --git a/tests/unit/repository/message-public-readback.test.ts b/tests/unit/repository/message-public-readback.test.ts index 8b2e49ea9..47b119bf6 100644 --- a/tests/unit/repository/message-public-readback.test.ts +++ b/tests/unit/repository/message-public-readback.test.ts @@ -61,7 +61,7 @@ const MESSAGE_ROW = { ...LATEST_ROW, model: "gpt-4.1", originalModel: "gpt-4.1-mini", - ttfbMs: 120, + tfftMs: 120, costMultiplier: "1.5", sessionId: "public-session", userAgent: "vitest", @@ -105,7 +105,7 @@ const LEDGER_ROW = { context1mApplied: false, swapCacheTtlApplied: true, durationMs: 1_500, - ttfbMs: 250, + tfftMs: 250, sessionId: "ledger-session", createdAt: CREATED_AT, }; diff --git a/tests/unit/repository/message-public-status-rollup.test.ts b/tests/unit/repository/message-public-status-rollup.test.ts index 864a1af78..70c96b18b 100644 --- a/tests/unit/repository/message-public-status-rollup.test.ts +++ b/tests/unit/repository/message-public-status-rollup.test.ts @@ -440,7 +440,7 @@ describe("repository/message public status rollup hook", () => { const finalDetails = { statusCode: 200, - ttfbMs: 200, + tfftMs: 200, outputTokens: 50, providerChain: [ { @@ -469,7 +469,7 @@ describe("repository/message public status rollup hook", () => { originalModel: "gpt-4.1", model: "gpt-4.1", outputTokens: 50, - ttfbMs: 200, + tfftMs: 200, }), }) ); @@ -489,7 +489,7 @@ describe("repository/message public status rollup hook", () => { await updateMessageRequestDetails(202, { statusCode: 200, - ttfbMs: 250, + tfftMs: 250, outputTokens: 75, providerChain: [ { @@ -513,7 +513,7 @@ describe("repository/message public status rollup hook", () => { originalModel: "gpt-4.1", model: "gpt-4.1", outputTokens: 75, - ttfbMs: 250, + tfftMs: 250, }), }) ); @@ -577,7 +577,7 @@ describe("repository/message public status rollup hook", () => { const finalDetails = { statusCode: 200, - ttfbMs: 300, + tfftMs: 300, outputTokens: 90, providerChain: [ { @@ -604,7 +604,7 @@ describe("repository/message public status rollup hook", () => { createdAt: new Date("2026-04-21T10:04:00.000Z"), durationMs: 1800, outputTokens: 90, - ttfbMs: 300, + tfftMs: 300, }), }) ); @@ -661,7 +661,7 @@ describe("repository/message public status rollup hook", () => { const finalDetails = { statusCode: 200, - ttfbMs: 300, + tfftMs: 300, outputTokens: 90, providerChain: [ { @@ -750,7 +750,7 @@ describe("repository/message public status rollup hook", () => { const finalDetails = { statusCode: 200, - ttfbMs: 320, + tfftMs: 320, outputTokens: 95, providerChain: [ { @@ -776,7 +776,7 @@ describe("repository/message public status rollup hook", () => { createdAt: new Date("2026-04-21T10:06:00.000Z"), durationMs: 1900, outputTokens: 95, - ttfbMs: 320, + tfftMs: 320, }), }) ); diff --git a/tests/unit/repository/message-session-readback.test.ts b/tests/unit/repository/message-session-readback.test.ts index 215915026..895a85363 100644 --- a/tests/unit/repository/message-session-readback.test.ts +++ b/tests/unit/repository/message-session-readback.test.ts @@ -101,7 +101,7 @@ const LEDGER_ROW = { context1mApplied: true, swapCacheTtlApplied: false, durationMs: 1_200, - ttfbMs: 200, + tfftMs: 200, sessionId: "ledger-session-readback", createdAt: CREATED_AT, }; diff --git a/tests/unit/repository/message-terminal-public-status-seam.test.ts b/tests/unit/repository/message-terminal-public-status-seam.test.ts index e684da8ef..5600e335e 100644 --- a/tests/unit/repository/message-terminal-public-status-seam.test.ts +++ b/tests/unit/repository/message-terminal-public-status-seam.test.ts @@ -52,246 +52,246 @@ describe("message terminal public-status public seam", () => { vi.doUnmock("@/lib/redis"); }); - it.each(["primary-first", "fallback-first"])( - "%s publishes exactly one rollup from the terminal SQL owner", - async (ownerOrder) => { - vi.resetModules(); - vi.useFakeTimers(); - - const id = ownerOrder === "primary-first" ? 91_001 : 91_002; - const row: TerminalRow = { - id, - createdAt: new Date("2026-07-13T12:00:00.000Z"), - model: "gpt-4.1", - originalModel: "gpt-4.1", - durationMs: null, - statusCode: null, - }; - const releasePrimary = createDeferred(); - const primaryReceipts: number[][] = []; - const fallbackReceipts: number[][] = []; - const primarySql: Array<{ sql: string; params: unknown[] }> = []; - const rollupPipelines: Array> = []; - - const primaryDetails = { - durationMs: 1_200, - statusCode: 200, - outputTokens: 60, - providerChain: [ - { - id: 1, - name: "primary-provider", - groupTag: "openai", - reason: "request_success" as const, - statusCode: 200, - }, - ], - model: "gpt-4.1", - }; - const fallbackDetails = { - durationMs: 2_400, - statusCode: 504, - outputTokens: 0, - errorMessage: "Error: stream_finalization_timeout", - providerChain: [ - { - id: 2, - name: "fallback-provider", - groupTag: "openai", - reason: "retry_failed" as const, - statusCode: 504, - }, - ], - model: "gpt-4.1", - }; + it.each([ + "primary-first", + "fallback-first", + ])("%s publishes exactly one rollup from the terminal SQL owner", async (ownerOrder) => { + vi.resetModules(); + vi.useFakeTimers(); - const execute = vi.fn(async (query: Parameters[0]) => { - const built = toSqlText(query); - primarySql.push(built); - await releasePrimary.promise; - if (row.statusCode !== null) { - primaryReceipts.push([]); - return []; - } - row.durationMs = primaryDetails.durationMs; - row.statusCode = primaryDetails.statusCode; - primaryReceipts.push([id]); - return [{ id }]; - }); - - const writerUpdate = vi.fn(() => ({ - set: vi.fn((patch: Record) => ({ - where: vi.fn(() => ({ - returning: vi.fn(async () => { - if (row.statusCode !== null) { - fallbackReceipts.push([]); - return []; - } - row.durationMs = patch.durationMs as number; - row.statusCode = patch.statusCode as number; - fallbackReceipts.push([id]); - return [{ id }]; - }), - })), + const id = ownerOrder === "primary-first" ? 91_001 : 91_002; + const row: TerminalRow = { + id, + createdAt: new Date("2026-07-13T12:00:00.000Z"), + model: "gpt-4.1", + originalModel: "gpt-4.1", + durationMs: null, + statusCode: null, + }; + const releasePrimary = createDeferred(); + const primaryReceipts: number[][] = []; + const fallbackReceipts: number[][] = []; + const primarySql: Array<{ sql: string; params: unknown[] }> = []; + const rollupPipelines: Array> = []; + + const primaryDetails = { + durationMs: 1_200, + statusCode: 200, + outputTokens: 60, + providerChain: [ + { + id: 1, + name: "primary-provider", + groupTag: "openai", + reason: "request_success" as const, + statusCode: 200, + }, + ], + model: "gpt-4.1", + }; + const fallbackDetails = { + durationMs: 2_400, + statusCode: 504, + outputTokens: 0, + errorMessage: "Error: stream_finalization_timeout", + providerChain: [ + { + id: 2, + name: "fallback-provider", + groupTag: "openai", + reason: "retry_failed" as const, + statusCode: 504, + }, + ], + model: "gpt-4.1", + }; + + const execute = vi.fn(async (query: Parameters[0]) => { + const built = toSqlText(query); + primarySql.push(built); + await releasePrimary.promise; + if (row.statusCode !== null) { + primaryReceipts.push([]); + return []; + } + row.durationMs = primaryDetails.durationMs; + row.statusCode = primaryDetails.statusCode; + primaryReceipts.push([id]); + return [{ id }]; + }); + + const writerUpdate = vi.fn(() => ({ + set: vi.fn((patch: Record) => ({ + where: vi.fn(() => ({ + returning: vi.fn(async () => { + if (row.statusCode !== null) { + fallbackReceipts.push([]); + return []; + } + row.durationMs = patch.durationMs as number; + row.statusCode = patch.statusCode as number; + fallbackReceipts.push([id]); + return [{ id }]; + }), })), - })); - const writerDb = { execute, update: writerUpdate }; - - vi.doMock("@/drizzle/db", () => ({ - db: { - select: vi.fn(() => ({ - from: vi.fn(() => ({ - where: vi.fn(() => ({ - limit: vi.fn(async () => [ - { - createdAt: row.createdAt, - model: row.model, - originalModel: row.originalModel, - durationMs: row.durationMs, - }, - ]), - })), + })), + })); + const writerDb = { execute, update: writerUpdate }; + + vi.doMock("@/drizzle/db", () => ({ + db: { + select: vi.fn(() => ({ + from: vi.fn(() => ({ + where: vi.fn(() => ({ + limit: vi.fn(async () => [ + { + createdAt: row.createdAt, + model: row.model, + originalModel: row.originalModel, + durationMs: row.durationMs, + }, + ]), })), })), - update: vi.fn(), - }, - getMessageWriterDb: vi.fn(() => writerDb), - })); - vi.doMock("@/lib/config/env.schema", () => ({ - getEnvConfig: () => ({ - MESSAGE_REQUEST_WRITE_MODE: "async", - MESSAGE_REQUEST_ASYNC_FLUSH_INTERVAL_MS: 60_000, - MESSAGE_REQUEST_ASYNC_BATCH_SIZE: 1_000, - MESSAGE_REQUEST_ASYNC_MAX_PENDING: 1_000, - }), - })); - vi.doMock("@/lib/logger", () => ({ - logger: { - trace: vi.fn(), - debug: vi.fn(), - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), + })), + update: vi.fn(), + }, + getMessageWriterDb: vi.fn(() => writerDb), + })); + vi.doMock("@/lib/config/env.schema", () => ({ + getEnvConfig: () => ({ + MESSAGE_REQUEST_WRITE_MODE: "async", + MESSAGE_REQUEST_ASYNC_FLUSH_INTERVAL_MS: 60_000, + MESSAGE_REQUEST_ASYNC_BATCH_SIZE: 1_000, + MESSAGE_REQUEST_ASYNC_MAX_PENDING: 1_000, + }), + })); + vi.doMock("@/lib/logger", () => ({ + logger: { + trace: vi.fn(), + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, + })); + + const configSnapshot = JSON.stringify({ + configVersion: "cfg-r2-seam", + generatedAt: "2026-07-13T11:59:00.000Z", + siteTitle: "Status", + siteDescription: "Status", + timeZone: "UTC", + defaultIntervalMinutes: 5, + defaultRangeHours: 24, + groups: [ + { + sourceGroupId: 42, + sourceGroupName: "openai", + slug: "openai", + displayName: "OpenAI", + sortOrder: 1, + description: null, + models: [ + { + publicModelKey: "gpt-4.1", + label: "GPT-4.1", + vendorIconKey: "openai", + requestTypeBadge: "openaiCompatible", + }, + ], }, - })); - - const configSnapshot = JSON.stringify({ - configVersion: "cfg-r2-seam", - generatedAt: "2026-07-13T11:59:00.000Z", - siteTitle: "Status", - siteDescription: "Status", - timeZone: "UTC", - defaultIntervalMinutes: 5, - defaultRangeHours: 24, - groups: [ - { - sourceGroupId: 42, - sourceGroupName: "openai", - slug: "openai", - displayName: "OpenAI", - sortOrder: 1, - description: null, - models: [ - { - publicModelKey: "gpt-4.1", - label: "GPT-4.1", - vendorIconKey: "openai", - requestTypeBadge: "openaiCompatible", - }, - ], + ], + }); + const redis = { + status: "ready", + hincrbyfloat: vi.fn(), + get: vi.fn(async (key: string) => { + if (key === "public-status:v2:config-version:current") { + return "cfg-r2-seam"; + } + if (key === "public-status:v2:config-internal:cfg-r2-seam") { + return configSnapshot; + } + return null; + }), + pipeline: vi.fn(() => { + const operations: Array<{ command: string; args: unknown[] }> = []; + return { + hincrbyfloat: (...args: unknown[]) => { + operations.push({ command: "hincrbyfloat", args }); }, - ], - }); - const redis = { - status: "ready", - hincrbyfloat: vi.fn(), - get: vi.fn(async (key: string) => { - if (key === "public-status:v2:config-version:current") { - return "cfg-r2-seam"; - } - if (key === "public-status:v2:config-internal:cfg-r2-seam") { - return configSnapshot; - } - return null; - }), - pipeline: vi.fn(() => { - const operations: Array<{ command: string; args: unknown[] }> = []; - return { - hincrbyfloat: (...args: unknown[]) => { - operations.push({ command: "hincrbyfloat", args }); - }, - set: (...args: unknown[]) => { - operations.push({ command: "set", args }); - }, - expire: (...args: unknown[]) => { - operations.push({ command: "expire", args }); - }, - exec: async () => { - rollupPipelines.push(operations); - return operations.map(() => [null, 1] as [null, number]); - }, - }; - }), - }; - vi.doMock("@/lib/redis", () => ({ - getRedisClient: vi.fn(() => redis), - })); - - const { updateMessageRequestDetailsDurably, updateMessageRequestDetailsIfUnfinalized } = - await import("@/repository/message"); - const { flushMessageRequestWriteBuffer, stopMessageRequestWriteBuffer } = await import( - "@/repository/message-write-buffer" - ); - - const primary = updateMessageRequestDetailsDurably(id, primaryDetails, { timeoutMs: 10 }); - const primaryResult = primary.catch((error: unknown) => error); - const flush = flushMessageRequestWriteBuffer(); - - await vi.advanceTimersByTimeAsync(10); - await expect(primaryResult).resolves.toEqual( - expect.objectContaining({ - message: "durable message_request acknowledgement timed out", - }) - ); - - if (ownerOrder === "fallback-first") { - await updateMessageRequestDetailsIfUnfinalized(id, fallbackDetails); - releasePrimary.resolve(); - await flush; - } else { - releasePrimary.resolve(); - await flush; - await updateMessageRequestDetailsIfUnfinalized(id, fallbackDetails); - } - await flushMicrotasks(); - - expect(primarySql).toHaveLength(1); - expect(primarySql[0]?.sql).toMatch(/"?status_code"? IS NULL/); - expect(primarySql[0]?.sql).toContain("RETURNING id"); - expect(primaryReceipts).toEqual(ownerOrder === "primary-first" ? [[id]] : [[]]); - expect(fallbackReceipts).toEqual(ownerOrder === "fallback-first" ? [[id]] : [[]]); - expect(row).toMatchObject( - ownerOrder === "primary-first" - ? { durationMs: primaryDetails.durationMs, statusCode: primaryDetails.statusCode } - : { durationMs: fallbackDetails.durationMs, statusCode: fallbackDetails.statusCode } - ); - expect(redis.get.mock.calls).toEqual([ - ["public-status:v2:config-version:current"], - ["public-status:v2:config-internal:cfg-r2-seam"], - ]); - expect(rollupPipelines).toHaveLength(1); - - const rollupFields = rollupPipelines[0]! - .filter((operation) => operation.command === "hincrbyfloat") - .map((operation) => String(operation.args[1])); - const expectedMetric = ownerOrder === "primary-first" ? "success" : "failure"; - const losingMetric = ownerOrder === "primary-first" ? "failure" : "success"; - expect(rollupFields).toContain(`42|gpt-4.1|${expectedMetric}`); - expect(rollupFields).not.toContain(`42|gpt-4.1|${losingMetric}`); - - await stopMessageRequestWriteBuffer(); + set: (...args: unknown[]) => { + operations.push({ command: "set", args }); + }, + expire: (...args: unknown[]) => { + operations.push({ command: "expire", args }); + }, + exec: async () => { + rollupPipelines.push(operations); + return operations.map(() => [null, 1] as [null, number]); + }, + }; + }), + }; + vi.doMock("@/lib/redis", () => ({ + getRedisClient: vi.fn(() => redis), + })); + + const { updateMessageRequestDetailsDurably, updateMessageRequestDetailsIfUnfinalized } = + await import("@/repository/message"); + const { flushMessageRequestWriteBuffer, stopMessageRequestWriteBuffer } = await import( + "@/repository/message-write-buffer" + ); + + const primary = updateMessageRequestDetailsDurably(id, primaryDetails, { timeoutMs: 10 }); + const primaryResult = primary.catch((error: unknown) => error); + const flush = flushMessageRequestWriteBuffer(); + + await vi.advanceTimersByTimeAsync(10); + await expect(primaryResult).resolves.toEqual( + expect.objectContaining({ + message: "durable message_request acknowledgement timed out", + }) + ); + + if (ownerOrder === "fallback-first") { + await updateMessageRequestDetailsIfUnfinalized(id, fallbackDetails); + releasePrimary.resolve(); + await flush; + } else { + releasePrimary.resolve(); + await flush; + await updateMessageRequestDetailsIfUnfinalized(id, fallbackDetails); } - ); + await flushMicrotasks(); + + expect(primarySql).toHaveLength(1); + expect(primarySql[0]?.sql).toMatch(/"?status_code"? IS NULL/); + expect(primarySql[0]?.sql).toContain("RETURNING id"); + expect(primaryReceipts).toEqual(ownerOrder === "primary-first" ? [[id]] : [[]]); + expect(fallbackReceipts).toEqual(ownerOrder === "fallback-first" ? [[id]] : [[]]); + expect(row).toMatchObject( + ownerOrder === "primary-first" + ? { durationMs: primaryDetails.durationMs, statusCode: primaryDetails.statusCode } + : { durationMs: fallbackDetails.durationMs, statusCode: fallbackDetails.statusCode } + ); + expect(redis.get.mock.calls).toEqual([ + ["public-status:v2:config-version:current"], + ["public-status:v2:config-internal:cfg-r2-seam"], + ]); + expect(rollupPipelines).toHaveLength(1); + + const rollupFields = rollupPipelines[0]! + .filter((operation) => operation.command === "hincrbyfloat") + .map((operation) => String(operation.args[1])); + const expectedMetric = ownerOrder === "primary-first" ? "success" : "failure"; + const losingMetric = ownerOrder === "primary-first" ? "failure" : "success"; + expect(rollupFields).toContain(`42|gpt-4.1|${expectedMetric}`); + expect(rollupFields).not.toContain(`42|gpt-4.1|${losingMetric}`); + + await stopMessageRequestWriteBuffer(); + }); it("same-ID pending durable contention publishes one rollup from the first owner", async () => { vi.resetModules(); @@ -303,7 +303,7 @@ describe("message terminal public-status public seam", () => { statusCode: 502, inputTokens: 31, outputTokens: 3, - ttfbMs: 900, + tfftMs: 900, providerChain: [ { id: 11, @@ -321,7 +321,7 @@ describe("message terminal public-status public seam", () => { durationMs: 1_500, statusCode: 200, outputTokens: 96, - ttfbMs: 300, + tfftMs: 300, providerChain: [ { id: 22, @@ -337,7 +337,7 @@ describe("message terminal public-status public seam", () => { const row: TerminalRow & { inputTokens: number | null; outputTokens: number | null; - ttfbMs: number | null; + tfftMs: number | null; providerChain: unknown; providerId: number | null; } = { @@ -349,7 +349,7 @@ describe("message terminal public-status public seam", () => { statusCode: null, inputTokens: null, outputTokens: null, - ttfbMs: null, + tfftMs: null, providerChain: null, providerId: null, }; @@ -385,7 +385,7 @@ describe("message terminal public-status public seam", () => { row.statusCode = Number(readCaseValue("status_code")); row.inputTokens = Number(readCaseValue("input_tokens")); row.outputTokens = Number(readCaseValue("output_tokens")); - row.ttfbMs = Number(readCaseValue("ttfb_ms")); + row.tfftMs = Number(readCaseValue("ttfb_ms")); row.providerChain = JSON.parse(String(readCaseValue("provider_chain"))); row.providerId = Number(readCaseValue("provider_id")); return [{ id }]; @@ -525,7 +525,7 @@ describe("message terminal public-status public seam", () => { statusCode: oldFailureDetails.statusCode, inputTokens: oldFailureDetails.inputTokens, outputTokens: oldFailureDetails.outputTokens, - ttfbMs: oldFailureDetails.ttfbMs, + tfftMs: oldFailureDetails.tfftMs, providerChain: oldFailureDetails.providerChain, providerId: oldFailureDetails.providerId, }); diff --git a/tests/unit/repository/message-terminal-write-apis.test.ts b/tests/unit/repository/message-terminal-write-apis.test.ts index 970b61226..6dfd4bc27 100644 --- a/tests/unit/repository/message-terminal-write-apis.test.ts +++ b/tests/unit/repository/message-terminal-write-apis.test.ts @@ -245,7 +245,7 @@ describe("message terminal write APIs", () => { const details = { inputTokens: 101, outputTokens: 23, - ttfbMs: null, + tfftMs: null, cacheCreationInputTokens: 7, cacheReadInputTokens: 8, cacheCreation5mInputTokens: 3, diff --git a/tests/unit/repository/message-usage-logs-query.test.ts b/tests/unit/repository/message-usage-logs-query.test.ts index 1cc44667d..fe945e5a4 100644 --- a/tests/unit/repository/message-usage-logs-query.test.ts +++ b/tests/unit/repository/message-usage-logs-query.test.ts @@ -154,7 +154,7 @@ describe("message repository findUsageLogs", () => { context1mApplied: true, swapCacheTtlApplied: false, durationMs: 250, - ttfbMs: 40, + tfftMs: 40, sessionId: "session-ledger", createdAt, }, diff --git a/tests/unit/repository/message-write-buffer.test.ts b/tests/unit/repository/message-write-buffer.test.ts index 3213cfdda..d726d63d7 100644 --- a/tests/unit/repository/message-write-buffer.test.ts +++ b/tests/unit/repository/message-write-buffer.test.ts @@ -154,7 +154,7 @@ describe("message_request 异步批量写入", () => { } = await import("@/repository/message-write-buffer"); enqueueMessageRequestUpdate(42, { durationMs: 100 }); - enqueueMessageRequestUpdate(42, { statusCode: 200, ttfbMs: 10 }); + enqueueMessageRequestUpdate(42, { statusCode: 200, tfftMs: 10 }); await flushMessageRequestWriteBuffer(); await stopMessageRequestWriteBuffer(); @@ -1070,80 +1070,79 @@ describe("message_request 异步批量写入", () => { it.each([ { databaseOutcome: "成功", shouldReject: false }, { databaseOutcome: "失败", shouldReject: true }, - ])( - "executor 首次同步重入 stop 时应共享同一 Promise, 并等待 DB $databaseOutcome", - async ({ shouldReject }) => { - process.env.MESSAGE_REQUEST_WRITE_MODE = "async"; - - const databaseBarrier = createDeferred>(); - const databaseError = new Error("db unavailable"); - let reentrantStopPromise: Promise | undefined; - let stopMessageRequestWriteBuffer!: () => Promise; - - executeMock.mockImplementation((query) => { - if (!reentrantStopPromise) { - reentrantStopPromise = stopMessageRequestWriteBuffer(); - return databaseBarrier.promise; - } - return shouldReject - ? Promise.reject(databaseError) - : Promise.resolve(successfulRowsForQuery(query)); - }); - - const messageWriteBuffer = await import("@/repository/message-write-buffer"); - stopMessageRequestWriteBuffer = messageWriteBuffer.stopMessageRequestWriteBuffer; - messageWriteBuffer.enqueueMessageRequestUpdate(42, { durationMs: 100 }); - - const outerStopPromise = stopMessageRequestWriteBuffer(); - const reentrantPromise = reentrantStopPromise; - if (!reentrantPromise) { - throw new Error("executor did not synchronously re-enter stop"); + ])("executor 首次同步重入 stop 时应共享同一 Promise, 并等待 DB $databaseOutcome", async ({ + shouldReject, + }) => { + process.env.MESSAGE_REQUEST_WRITE_MODE = "async"; + + const databaseBarrier = createDeferred>(); + const databaseError = new Error("db unavailable"); + let reentrantStopPromise: Promise | undefined; + let stopMessageRequestWriteBuffer!: () => Promise; + + executeMock.mockImplementation((query) => { + if (!reentrantStopPromise) { + reentrantStopPromise = stopMessageRequestWriteBuffer(); + return databaseBarrier.promise; } - const samePromise = outerStopPromise === reentrantPromise; - let outerSettled = false; - let reentrantSettled = false; - void outerStopPromise.then( - () => { - outerSettled = true; - }, - () => { - outerSettled = true; - } - ); - void reentrantPromise.then( - () => { - reentrantSettled = true; - }, - () => { - reentrantSettled = true; - } - ); - await new Promise((resolve) => setImmediate(resolve)); - const settlementsBeforeRelease = [outerSettled, reentrantSettled]; + return shouldReject + ? Promise.reject(databaseError) + : Promise.resolve(successfulRowsForQuery(query)); + }); + + const messageWriteBuffer = await import("@/repository/message-write-buffer"); + stopMessageRequestWriteBuffer = messageWriteBuffer.stopMessageRequestWriteBuffer; + messageWriteBuffer.enqueueMessageRequestUpdate(42, { durationMs: 100 }); - if (shouldReject) { - databaseBarrier.reject(databaseError); - } else { - databaseBarrier.resolve([]); + const outerStopPromise = stopMessageRequestWriteBuffer(); + const reentrantPromise = reentrantStopPromise; + if (!reentrantPromise) { + throw new Error("executor did not synchronously re-enter stop"); + } + const samePromise = outerStopPromise === reentrantPromise; + let outerSettled = false; + let reentrantSettled = false; + void outerStopPromise.then( + () => { + outerSettled = true; + }, + () => { + outerSettled = true; } - const stopResults = await Promise.allSettled([outerStopPromise, reentrantPromise]); - - expect(settlementsBeforeRelease).toEqual([false, false]); - if (shouldReject) { - const shutdownError = "message_request writer shutdown persistence failed"; - expect(stopResults).toEqual([ - { status: "rejected", reason: expect.objectContaining({ message: shutdownError }) }, - { status: "rejected", reason: expect.objectContaining({ message: shutdownError }) }, - ]); - } else { - expect(stopResults).toEqual([ - { status: "fulfilled", value: undefined }, - { status: "fulfilled", value: undefined }, - ]); + ); + void reentrantPromise.then( + () => { + reentrantSettled = true; + }, + () => { + reentrantSettled = true; } - expect(samePromise).toBe(true); + ); + await new Promise((resolve) => setImmediate(resolve)); + const settlementsBeforeRelease = [outerSettled, reentrantSettled]; + + if (shouldReject) { + databaseBarrier.reject(databaseError); + } else { + databaseBarrier.resolve([]); } - ); + const stopResults = await Promise.allSettled([outerStopPromise, reentrantPromise]); + + expect(settlementsBeforeRelease).toEqual([false, false]); + if (shouldReject) { + const shutdownError = "message_request writer shutdown persistence failed"; + expect(stopResults).toEqual([ + { status: "rejected", reason: expect.objectContaining({ message: shutdownError }) }, + { status: "rejected", reason: expect.objectContaining({ message: shutdownError }) }, + ]); + } else { + expect(stopResults).toEqual([ + { status: "fulfilled", value: undefined }, + { status: "fulfilled", value: undefined }, + ]); + } + expect(samePromise).toBe(true); + }); it("stop 无法刷写剩余终态时所有调用都应持续拒绝同一错误", async () => { process.env.MESSAGE_REQUEST_WRITE_MODE = "async"; diff --git a/tests/unit/repository/usage-logs-actual-response-model.test.ts b/tests/unit/repository/usage-logs-actual-response-model.test.ts index 68700b987..b057d226b 100644 --- a/tests/unit/repository/usage-logs-actual-response-model.test.ts +++ b/tests/unit/repository/usage-logs-actual-response-model.test.ts @@ -50,7 +50,7 @@ describe("findUsageLogsBatch: actualResponseModel propagation", () => { groupCostMultiplier: null, costBreakdown: null, durationMs: 500, - ttfbMs: 100, + tfftMs: 100, errorMessage: null, providerChain: null, blockedBy: null, @@ -118,7 +118,7 @@ describe("findUsageLogsBatch: actualResponseModel propagation", () => { costMultiplier: null, groupCostMultiplier: null, durationMs: 400, - ttfbMs: 80, + tfftMs: 80, clientIp: null, context1mApplied: false, swapCacheTtlApplied: false, @@ -178,7 +178,7 @@ describe("findUsageLogsBatch: actualResponseModel propagation", () => { costMultiplier: null, groupCostMultiplier: null, durationMs: 0, - ttfbMs: 0, + tfftMs: 0, clientIp: null, context1mApplied: false, swapCacheTtlApplied: false, diff --git a/tests/unit/repository/usage-logs-sessionid-filter.test.ts b/tests/unit/repository/usage-logs-sessionid-filter.test.ts index ab3ae66e8..3ace29fc2 100644 --- a/tests/unit/repository/usage-logs-sessionid-filter.test.ts +++ b/tests/unit/repository/usage-logs-sessionid-filter.test.ts @@ -137,7 +137,7 @@ describe("Usage logs sessionId filter", () => { costUsd: "0.01", costMultiplier: null, durationMs: 10, - ttfbMs: 5, + tfftMs: 5, errorMessage: null, providerChain: null, blockedBy: null, @@ -171,7 +171,7 @@ describe("Usage logs sessionId filter", () => { costUsd: "0.01", costMultiplier: null, durationMs: 10, - ttfbMs: 5, + tfftMs: 5, errorMessage: null, providerChain: null, blockedBy: null, From 7d1caa4dcb57be1a54baeafd2f27565666f81433 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 25 Jul 2026 04:37:20 +0000 Subject: [PATCH 3/4] chore: format code (fix-tfft-ui-tps-calc-ed012ff) --- src/lib/provider-testing/test-service.test.ts | 50 +- .../utils/upstream-error-detection.test.ts | 14 +- .../actions/providers-patch-contract.test.ts | 28 +- .../api/actions/legacy-deprecation.test.ts | 62 +-- tests/unit/api/v1/status-code-map.test.ts | 13 +- tests/unit/i18n/key-created-copy.test.ts | 41 +- .../instrumentation-crash-handler.test.ts | 26 +- .../lib/provider-allowed-model-schema.test.ts | 23 +- .../probe-scheduler.test.ts | 91 ++-- .../provider-model-redirect-schema.test.ts | 25 +- tests/unit/lib/redis/client.test.ts | 28 +- .../lib/session-manager-binding-smart.test.ts | 78 +-- .../upstream-error-detection-status.test.ts | 168 +++---- tests/unit/proxy/client-detector.test.ts | 13 +- .../proxy/codex-provider-overrides.test.ts | 102 ++-- .../connected-non-reader-lifetime.test.ts | 40 +- .../proxy/endpoint-family-catalog.test.ts | 11 +- .../endpoint-family-provider-routing.test.ts | 45 +- .../proxy/endpoint-path-normalization.test.ts | 20 +- .../error-handler-terminal-status.test.ts | 67 +-- .../fake-streaming-response-validator.test.ts | 86 ++-- .../proxy/fake-streaming-response.test.ts | 16 +- .../fake-streaming-stream-intent.test.ts | 150 +++--- ...provider-selector-cross-type-model.test.ts | 27 +- .../proxy-forwarder-endpoint-audit.test.ts | 113 ++--- .../proxy-forwarder-hedge-first-byte.test.ts | 222 +++++---- .../proxy/proxy-forwarder-retry-limit.test.ts | 110 ++--- ...esponse-handler-client-abort-drain.test.ts | 242 ++++----- ...handler-endpoint-circuit-isolation.test.ts | 92 ++-- tests/unit/proxy/session.test.ts | 26 +- ...essage-terminal-public-status-seam.test.ts | 466 +++++++++--------- .../repository/message-write-buffer.test.ts | 137 ++--- ...server-response-write-backpressure.test.ts | 88 ++-- 33 files changed, 1356 insertions(+), 1364 deletions(-) diff --git a/src/lib/provider-testing/test-service.test.ts b/src/lib/provider-testing/test-service.test.ts index 49fa29ef6..18d093435 100644 --- a/src/lib/provider-testing/test-service.test.ts +++ b/src/lib/provider-testing/test-service.test.ts @@ -169,33 +169,33 @@ describe("executeProviderTest", () => { expectRequestUrl("https://relay.example.com/openai/v1/responses"); }); - test.each([ - "https://api.gptclubapi.xyz/openai", - "https://api.gptclubapi.xyz/openai/", - ])("codex bare /openai base preserves absolute versioned request url: %s", async (providerUrl) => { - mockJsonResponse({ - id: "resp_test", - model: "gpt-5.5", - output: [ - { - type: "message", - role: "assistant", - content: [{ type: "output_text", text: "pong" }], - }, - ], - }); + test.each(["https://api.gptclubapi.xyz/openai", "https://api.gptclubapi.xyz/openai/"])( + "codex bare /openai base preserves absolute versioned request url: %s", + async (providerUrl) => { + mockJsonResponse({ + id: "resp_test", + model: "gpt-5.5", + output: [ + { + type: "message", + role: "assistant", + content: [{ type: "output_text", text: "pong" }], + }, + ], + }); - const result = await executeProviderTest({ - providerUrl, - apiKey: "sk-test-codex", - providerType: "codex", - model: "gpt-5.5", - }); + const result = await executeProviderTest({ + providerUrl, + apiKey: "sk-test-codex", + providerType: "codex", + model: "gpt-5.5", + }); - expect(result.success).toBe(true); - expect(result.requestUrl).toBe("https://api.gptclubapi.xyz/openai/v1/responses"); - expect(fetchMock.mock.calls[0]?.[0]).toBe("https://api.gptclubapi.xyz/openai/v1/responses"); - }); + expect(result.success).toBe(true); + expect(result.requestUrl).toBe("https://api.gptclubapi.xyz/openai/v1/responses"); + expect(fetchMock.mock.calls[0]?.[0]).toBe("https://api.gptclubapi.xyz/openai/v1/responses"); + } + ); test("openai-compatible 版本根路径应只追加 endpoint,不重复拼接 /v1", async () => { mockJsonResponse({ diff --git a/src/lib/utils/upstream-error-detection.test.ts b/src/lib/utils/upstream-error-detection.test.ts index 957ef374b..1b35ad6eb 100644 --- a/src/lib/utils/upstream-error-detection.test.ts +++ b/src/lib/utils/upstream-error-detection.test.ts @@ -74,13 +74,13 @@ describe("detectUpstreamErrorFromSseOrJsonText", () => { expect(res.isError).toBe(true); }); - test.each([ - '{"error":true}', - '{"error":42}', - ])("纯 JSON:error 为非字符串类型也应视为错误(%s)", (body) => { - const res = detectUpstreamErrorFromSseOrJsonText(body); - expect(res.isError).toBe(true); - }); + test.each(['{"error":true}', '{"error":42}'])( + "纯 JSON:error 为非字符串类型也应视为错误(%s)", + (body) => { + const res = detectUpstreamErrorFromSseOrJsonText(body); + expect(res.isError).toBe(true); + } + ); test("JSON 数组输入不视为错误(目前不做解析)", () => { const res = detectUpstreamErrorFromSseOrJsonText('[{"error":"something"}]'); diff --git a/tests/unit/actions/providers-patch-contract.test.ts b/tests/unit/actions/providers-patch-contract.test.ts index 86274dd20..3e6a04e23 100644 --- a/tests/unit/actions/providers-patch-contract.test.ts +++ b/tests/unit/actions/providers-patch-contract.test.ts @@ -851,21 +851,19 @@ describe("provider patch contract", () => { }); describe("MCP fields", () => { - it.each([ - "none", - "minimax", - "glm", - "custom", - ] as const)("accepts mcp_passthrough_type value: %s", (value) => { - const result = prepareProviderBatchApplyUpdates({ - mcp_passthrough_type: { set: value }, - }); - - expect(result.ok).toBe(true); - if (!result.ok) return; - - expect(result.data.mcp_passthrough_type).toBe(value); - }); + it.each(["none", "minimax", "glm", "custom"] as const)( + "accepts mcp_passthrough_type value: %s", + (value) => { + const result = prepareProviderBatchApplyUpdates({ + mcp_passthrough_type: { set: value }, + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + + expect(result.data.mcp_passthrough_type).toBe(value); + } + ); it("rejects invalid mcp_passthrough_type value", () => { const result = normalizeProviderBatchPatchDraft({ diff --git a/tests/unit/api/actions/legacy-deprecation.test.ts b/tests/unit/api/actions/legacy-deprecation.test.ts index 4292eac45..f1740c44c 100644 --- a/tests/unit/api/actions/legacy-deprecation.test.ts +++ b/tests/unit/api/actions/legacy-deprecation.test.ts @@ -75,20 +75,20 @@ describe("legacy actions API deprecation", () => { expectManagementSecurityHeaders(response); }); - test.each([ - "/api/actions/docs", - "/api/actions/scalar", - ])("keeps legacy docs UI %s available when execution is disabled but docs mode is deprecated", async (pathname) => { - vi.stubEnv("ENABLE_LEGACY_ACTIONS_API", "false"); - vi.stubEnv("LEGACY_ACTIONS_DOCS_MODE", "deprecated"); - - const response = await callFreshActionsRoute(pathname, "GET"); - - expect(response.status).toBe(200); - expect(response.headers.get("Deprecation")).toBe("@1777420800"); - expect(response.headers.get("Link")).toContain("/api/v1/openapi.json"); - expectManagementSecurityHeaders(response); - }); + test.each(["/api/actions/docs", "/api/actions/scalar"])( + "keeps legacy docs UI %s available when execution is disabled but docs mode is deprecated", + async (pathname) => { + vi.stubEnv("ENABLE_LEGACY_ACTIONS_API", "false"); + vi.stubEnv("LEGACY_ACTIONS_DOCS_MODE", "deprecated"); + + const response = await callFreshActionsRoute(pathname, "GET"); + + expect(response.status).toBe(200); + expect(response.headers.get("Deprecation")).toBe("@1777420800"); + expect(response.headers.get("Link")).toContain("/api/v1/openapi.json"); + expectManagementSecurityHeaders(response); + } + ); test("keeps deprecation date stable when sunset date is overridden", async () => { vi.stubEnv("LEGACY_ACTIONS_SUNSET_DATE", "2027-01-15"); @@ -114,21 +114,21 @@ describe("legacy actions API deprecation", () => { }); }); - test.each([ - "/api/actions/docs", - "/api/actions/scalar", - ])("can hide legacy docs UI %s independently with the docs mode flag", async (pathname) => { - vi.stubEnv("ENABLE_LEGACY_ACTIONS_API", "true"); - vi.stubEnv("LEGACY_ACTIONS_DOCS_MODE", "hidden"); - - const response = await callFreshActionsRoute(pathname, "GET"); - const body = await response.json(); - - expect(response.status).toBe(410); - expect(body).toMatchObject({ - status: 410, - errorCode: "api.legacy_actions_gone", - instance: pathname, - }); - }); + test.each(["/api/actions/docs", "/api/actions/scalar"])( + "can hide legacy docs UI %s independently with the docs mode flag", + async (pathname) => { + vi.stubEnv("ENABLE_LEGACY_ACTIONS_API", "true"); + vi.stubEnv("LEGACY_ACTIONS_DOCS_MODE", "hidden"); + + const response = await callFreshActionsRoute(pathname, "GET"); + const body = await response.json(); + + expect(response.status).toBe(410); + expect(body).toMatchObject({ + status: 410, + errorCode: "api.legacy_actions_gone", + instance: pathname, + }); + } + ); }); diff --git a/tests/unit/api/v1/status-code-map.test.ts b/tests/unit/api/v1/status-code-map.test.ts index e38074a4b..ec62a2e57 100644 --- a/tests/unit/api/v1/status-code-map.test.ts +++ b/tests/unit/api/v1/status-code-map.test.ts @@ -15,10 +15,11 @@ describe("v1 status code map", () => { [415, "Unsupported media type", "request.unsupported_media_type"], [429, "Too many requests", "rate_limit.exceeded"], [503, "Service unavailable", "dependency.unavailable"], - ] as Array< - [ProblemStatusCode, string, string] - >)("maps %s to defaults", (status, title, errorCode) => { - expect(getDefaultProblemTitle(status)).toBe(title); - expect(getDefaultErrorCode(status)).toBe(errorCode); - }); + ] as Array<[ProblemStatusCode, string, string]>)( + "maps %s to defaults", + (status, title, errorCode) => { + expect(getDefaultProblemTitle(status)).toBe(title); + expect(getDefaultErrorCode(status)).toBe(errorCode); + } + ); }); diff --git a/tests/unit/i18n/key-created-copy.test.ts b/tests/unit/i18n/key-created-copy.test.ts index 5bcdaf7c0..1ba546dfb 100644 --- a/tests/unit/i18n/key-created-copy.test.ts +++ b/tests/unit/i18n/key-created-copy.test.ts @@ -65,31 +65,32 @@ function getString(messages: Record, keyPath: readonly string[] describe.each(LOCALES)("key creation copy (%s)", (locale) => { const dashboard = loadMessages(locale, "dashboard.json"); - test.each( - COPY_PATHS.map((p) => [p.join("."), p] as const) - )("%s matches the actual reveal behavior", (_label, keyPath) => { - const copy = getString(dashboard, keyPath); + test.each(COPY_PATHS.map((p) => [p.join("."), p] as const))( + "%s matches the actual reveal behavior", + (_label, keyPath) => { + const copy = getString(dashboard, keyPath); - expect(copy.trim().length).toBeGreaterThan(0); - for (const pattern of ONE_TIME_CLAIM_PATTERNS) { - expect(copy).not.toMatch(pattern); + expect(copy.trim().length).toBeGreaterThan(0); + for (const pattern of ONE_TIME_CLAIM_PATTERNS) { + expect(copy).not.toMatch(pattern); + } + expect(copy).toMatch(REVIEWABLE_MARKERS[locale]); } - expect(copy).toMatch(REVIEWABLE_MARKERS[locale]); - }); + ); }); describe.each(LOCALES)("removeKey error code translations (%s)", (locale) => { const errors = loadMessages(locale, "errors.json"); - test.each([ - "CANNOT_DELETE_LAST_KEY", - "CANNOT_DELETE_LAST_GROUP_KEY", - ])("errors namespace translates %s", (code) => { - const value = errors[code]; - expect(value, `${locale}/errors.json must define ${code}`).toBeTypeOf("string"); - expect((value as string).trim().length).toBeGreaterThan(0); - // Must be a distinct, specific message rather than a copy of a generic one. - expect(value).not.toBe(errors.OPERATION_FAILED); - expect(value).not.toBe(errors.DELETE_FAILED); - }); + test.each(["CANNOT_DELETE_LAST_KEY", "CANNOT_DELETE_LAST_GROUP_KEY"])( + "errors namespace translates %s", + (code) => { + const value = errors[code]; + expect(value, `${locale}/errors.json must define ${code}`).toBeTypeOf("string"); + expect((value as string).trim().length).toBeGreaterThan(0); + // Must be a distinct, specific message rather than a copy of a generic one. + expect(value).not.toBe(errors.OPERATION_FAILED); + expect(value).not.toBe(errors.DELETE_FAILED); + } + ); }); diff --git a/tests/unit/instrumentation-crash-handler.test.ts b/tests/unit/instrumentation-crash-handler.test.ts index 256a9ea82..b195e69b4 100644 --- a/tests/unit/instrumentation-crash-handler.test.ts +++ b/tests/unit/instrumentation-crash-handler.test.ts @@ -171,19 +171,19 @@ describe("registerCrashDiagnostics", () => { expect(logger.fatal).toHaveBeenCalledTimes(1); }); - it.each([ - "ECONNRESET", - "ERR_STREAM_PREMATURE_CLOSE", - ])("uncaughtException: ambiguous code %s is NOT suppressed and still exits with code 1", (code) => { - // 这些码方向不明(可能来自上游 DB/Redis/provider),进程级无上下文区分, - // 必须保持 fail-fast,避免误吞真正的基础设施故障。 - const { uncaughtException } = captureHandlers(); - uncaughtException(makeError(code)); - - expect(exitSpy).toHaveBeenCalledWith(1); - expect(logger.fatal).toHaveBeenCalledTimes(1); - expect(logger.warn).not.toHaveBeenCalled(); - }); + it.each(["ECONNRESET", "ERR_STREAM_PREMATURE_CLOSE"])( + "uncaughtException: ambiguous code %s is NOT suppressed and still exits with code 1", + (code) => { + // 这些码方向不明(可能来自上游 DB/Redis/provider),进程级无上下文区分, + // 必须保持 fail-fast,避免误吞真正的基础设施故障。 + const { uncaughtException } = captureHandlers(); + uncaughtException(makeError(code)); + + expect(exitSpy).toHaveBeenCalledWith(1); + expect(logger.fatal).toHaveBeenCalledTimes(1); + expect(logger.warn).not.toHaveBeenCalled(); + } + ); it("unhandledRejection: a generic rejection exits with code 1", () => { const { unhandledRejection } = captureHandlers(); diff --git a/tests/unit/lib/provider-allowed-model-schema.test.ts b/tests/unit/lib/provider-allowed-model-schema.test.ts index 9787daab8..44c2deddc 100644 --- a/tests/unit/lib/provider-allowed-model-schema.test.ts +++ b/tests/unit/lib/provider-allowed-model-schema.test.ts @@ -51,20 +51,17 @@ describe("provider-allowed-model-schema", () => { }); describe("regex 模式的 glob 通配符兼容", () => { - it.each<[string]>([ - ["*"], - ["*."], - ["claude-*"], - ["*-opus-*"], - ["?"], - ])("接受 glob 风格的 pattern: %s", (pattern) => { - const result = PROVIDER_ALLOWED_MODEL_RULE_SCHEMA.safeParse({ - matchType: "regex", - pattern, - }); + it.each<[string]>([["*"], ["*."], ["claude-*"], ["*-opus-*"], ["?"]])( + "接受 glob 风格的 pattern: %s", + (pattern) => { + const result = PROVIDER_ALLOWED_MODEL_RULE_SCHEMA.safeParse({ + matchType: "regex", + pattern, + }); - expect(result.success).toBe(true); - }); + expect(result.success).toBe(true); + } + ); it("仍然拒绝纯粹无法解析的正则", () => { const result = PROVIDER_ALLOWED_MODEL_RULE_SCHEMA.safeParse({ diff --git a/tests/unit/lib/provider-endpoints/probe-scheduler.test.ts b/tests/unit/lib/provider-endpoints/probe-scheduler.test.ts index 9409f7b23..f2142995e 100644 --- a/tests/unit/lib/provider-endpoints/probe-scheduler.test.ts +++ b/tests/unit/lib/provider-endpoints/probe-scheduler.test.ts @@ -119,54 +119,51 @@ describe("provider-endpoints: probe scheduler", () => { expect(loggerWarnMock).not.toHaveBeenCalled(); }); - test.each([ - "enabled", - "TRUE", - " false ", - ])("invalid scheduler switch %s warns and falls back to enabled", async (value) => { - vi.resetModules(); - vi.stubEnv("ENDPOINT_PROBE_SCHEDULER_ENABLED", value); - vi.stubEnv("ENDPOINT_PROBE_TIMEOUT_RETRY_INTERVAL_MS", undefined); - - const { getEndpointProbeSchedulerStatus } = await import( - "@/lib/provider-endpoints/probe-scheduler" - ); - - expect(getEndpointProbeSchedulerStatus().enabled).toBe(true); - expect(loggerWarnMock).toHaveBeenCalledWith( - "[EndpointProbeScheduler] Invalid environment variable, using default", - { - name: "ENDPOINT_PROBE_SCHEDULER_ENABLED", - value, - defaultValue: true, - } - ); - }); - - test.each([ - "10000ms", - "0", - "-1", - "1.5", - ])("invalid timeout retry interval %s warns and falls back to 10000", async (value) => { - vi.resetModules(); - vi.stubEnv("ENDPOINT_PROBE_SCHEDULER_ENABLED", undefined); - vi.stubEnv("ENDPOINT_PROBE_TIMEOUT_RETRY_INTERVAL_MS", value); - - const { getEndpointProbeSchedulerStatus } = await import( - "@/lib/provider-endpoints/probe-scheduler" - ); + test.each(["enabled", "TRUE", " false "])( + "invalid scheduler switch %s warns and falls back to enabled", + async (value) => { + vi.resetModules(); + vi.stubEnv("ENDPOINT_PROBE_SCHEDULER_ENABLED", value); + vi.stubEnv("ENDPOINT_PROBE_TIMEOUT_RETRY_INTERVAL_MS", undefined); + + const { getEndpointProbeSchedulerStatus } = await import( + "@/lib/provider-endpoints/probe-scheduler" + ); + + expect(getEndpointProbeSchedulerStatus().enabled).toBe(true); + expect(loggerWarnMock).toHaveBeenCalledWith( + "[EndpointProbeScheduler] Invalid environment variable, using default", + { + name: "ENDPOINT_PROBE_SCHEDULER_ENABLED", + value, + defaultValue: true, + } + ); + } + ); - expect(getEndpointProbeSchedulerStatus().timeoutOverrideIntervalMs).toBe(10_000); - expect(loggerWarnMock).toHaveBeenCalledWith( - "[EndpointProbeScheduler] Invalid environment variable, using default", - { - name: "ENDPOINT_PROBE_TIMEOUT_RETRY_INTERVAL_MS", - value, - defaultValue: 10_000, - } - ); - }); + test.each(["10000ms", "0", "-1", "1.5"])( + "invalid timeout retry interval %s warns and falls back to 10000", + async (value) => { + vi.resetModules(); + vi.stubEnv("ENDPOINT_PROBE_SCHEDULER_ENABLED", undefined); + vi.stubEnv("ENDPOINT_PROBE_TIMEOUT_RETRY_INTERVAL_MS", value); + + const { getEndpointProbeSchedulerStatus } = await import( + "@/lib/provider-endpoints/probe-scheduler" + ); + + expect(getEndpointProbeSchedulerStatus().timeoutOverrideIntervalMs).toBe(10_000); + expect(loggerWarnMock).toHaveBeenCalledWith( + "[EndpointProbeScheduler] Invalid environment variable, using default", + { + name: "ENDPOINT_PROBE_TIMEOUT_RETRY_INTERVAL_MS", + value, + defaultValue: 10_000, + } + ); + } + ); test("disabled scheduler does not create a timer, acquire a lock, or query endpoints", async () => { vi.resetModules(); diff --git a/tests/unit/lib/provider-model-redirect-schema.test.ts b/tests/unit/lib/provider-model-redirect-schema.test.ts index a29461437..3766b181f 100644 --- a/tests/unit/lib/provider-model-redirect-schema.test.ts +++ b/tests/unit/lib/provider-model-redirect-schema.test.ts @@ -53,21 +53,18 @@ describe("provider-model-redirect-schema", () => { }); describe("regex 模式的 glob 通配符兼容", () => { - it.each<[string]>([ - ["*"], - ["*."], - ["claude-*"], - ["*-opus-*"], - ["?"], - ])("接受 glob 风格的 source: %s", (source) => { - const result = PROVIDER_MODEL_REDIRECT_RULE_SCHEMA.safeParse({ - matchType: "regex", - source, - target: "claude-sonnet-4-6", - }); + it.each<[string]>([["*"], ["*."], ["claude-*"], ["*-opus-*"], ["?"]])( + "接受 glob 风格的 source: %s", + (source) => { + const result = PROVIDER_MODEL_REDIRECT_RULE_SCHEMA.safeParse({ + matchType: "regex", + source, + target: "claude-sonnet-4-6", + }); - expect(result.success).toBe(true); - }); + expect(result.success).toBe(true); + } + ); it("仍然拒绝纯粹无法解析的正则", () => { const result = PROVIDER_MODEL_REDIRECT_RULE_SCHEMA.safeParse({ diff --git a/tests/unit/lib/redis/client.test.ts b/tests/unit/lib/redis/client.test.ts index 4f10892a1..4553739d5 100644 --- a/tests/unit/lib/redis/client.test.ts +++ b/tests/unit/lib/redis/client.test.ts @@ -61,20 +61,20 @@ describe("buildRedisOptionsForUrl", () => { expect(result.isTLS).toBe(true); }); - it.each([ - "redis://localhost:6379", - "rediss://localhost:6380", - ])("supports REDIS_COMMAND_TIMEOUT_MS override for %s", async (redisUrl) => { - process.env.REDIS_COMMAND_TIMEOUT_MS = "2500"; - vi.resetModules(); - const { buildRedisOptionsForUrl: buildFreshOptions } = await import("@/lib/redis/client"); - - const result = buildFreshOptions(redisUrl); - - expect(result.options.commandTimeout).toBe(2_500); - expect(result.options.socketTimeout).toBe(7_500); - expect(result.options.autoResendUnfulfilledCommands).toBe(false); - }); + it.each(["redis://localhost:6379", "rediss://localhost:6380"])( + "supports REDIS_COMMAND_TIMEOUT_MS override for %s", + async (redisUrl) => { + process.env.REDIS_COMMAND_TIMEOUT_MS = "2500"; + vi.resetModules(); + const { buildRedisOptionsForUrl: buildFreshOptions } = await import("@/lib/redis/client"); + + const result = buildFreshOptions(redisUrl); + + expect(result.options.commandTimeout).toBe(2_500); + expect(result.options.socketTimeout).toBe(7_500); + expect(result.options.autoResendUnfulfilledCommands).toBe(false); + } + ); }); describe("getRedisClient", () => { diff --git a/tests/unit/lib/session-manager-binding-smart.test.ts b/tests/unit/lib/session-manager-binding-smart.test.ts index 3244633ca..dbc0855b7 100644 --- a/tests/unit/lib/session-manager-binding-smart.test.ts +++ b/tests/unit/lib/session-manager-binding-smart.test.ts @@ -319,45 +319,45 @@ describe("SessionManager.updateSessionBindingSmart forceUpdate", () => { it.each([ { isFailoverSuccess: false, forceUpdate: true }, { isFailoverSuccess: true, forceUpdate: false }, - ])("does not let a stale versioned winner overwrite a newer binding (%o)", async ({ - isFailoverSuccess, - forceUpdate, - }) => { - bindingMocks.readOrReconcileSessionBinding.mockResolvedValue({ - status: "ok", - source: "existing", - snapshot: { - sessionId: SID, - keyId: KEY_ID, - providerId: 1, - generation: "generation-before-concurrent-update", - }, - legacyFallbackAllowed: false, - }); - bindingMocks.compareAndSetSessionBinding.mockResolvedValue({ - status: "conflict", - reason: "generation_mismatch", - legacyFallbackAllowed: false, - }); - - const result = await SessionManager.updateSessionBindingSmart( - SID, - 2, - 10, - false, - isFailoverSuccess, - KEY_ID, - forceUpdate - ); - - expect(result).toEqual({ - updated: false, - reason: "concurrent_binding_changed", - details: "Session binding changed before the update committed", - }); - expect(bindingMocks.compareAndSetSessionBinding).toHaveBeenCalledTimes(1); - expect(bindingMocks.mutateLegacySessionBindingSafely).not.toHaveBeenCalled(); - }); + ])( + "does not let a stale versioned winner overwrite a newer binding (%o)", + async ({ isFailoverSuccess, forceUpdate }) => { + bindingMocks.readOrReconcileSessionBinding.mockResolvedValue({ + status: "ok", + source: "existing", + snapshot: { + sessionId: SID, + keyId: KEY_ID, + providerId: 1, + generation: "generation-before-concurrent-update", + }, + legacyFallbackAllowed: false, + }); + bindingMocks.compareAndSetSessionBinding.mockResolvedValue({ + status: "conflict", + reason: "generation_mismatch", + legacyFallbackAllowed: false, + }); + + const result = await SessionManager.updateSessionBindingSmart( + SID, + 2, + 10, + false, + isFailoverSuccess, + KEY_ID, + forceUpdate + ); + + expect(result).toEqual({ + updated: false, + reason: "concurrent_binding_changed", + details: "Session binding changed before the update committed", + }); + expect(bindingMocks.compareAndSetSessionBinding).toHaveBeenCalledTimes(1); + expect(bindingMocks.mutateLegacySessionBindingSafely).not.toHaveBeenCalled(); + } + ); it("does not fall back to legacy writes for a foreign owner conflict", async () => { bindingMocks.readOrReconcileSessionBinding.mockResolvedValue({ diff --git a/tests/unit/lib/upstream-error-detection-status.test.ts b/tests/unit/lib/upstream-error-detection-status.test.ts index e34cf75a5..2bac09c5c 100644 --- a/tests/unit/lib/upstream-error-detection-status.test.ts +++ b/tests/unit/lib/upstream-error-detection-status.test.ts @@ -28,93 +28,87 @@ const cloudflareErrorCases = [ ] as const; describe("inferUpstreamErrorStatusCodeFromText numeric boundaries", () => { - it.each(httpStatusCases)("keeps matching a standalone HTTP $statusCode status token", ({ - statusCode, - matcherId, - }) => { - expect(inferUpstreamErrorStatusCodeFromText(`HTTP/1.1 ${statusCode}`)).toEqual({ - statusCode, - matcherId, - }); - }); - - it.each( - httpStatusCases - )("does not treat HTTP $statusCode followed by a decimal fraction as a status token", ({ - statusCode, - }) => { - expect(inferUpstreamErrorStatusCodeFromText(`HTTP/1.1 ${statusCode}.12`)).toBeNull(); - }); - - it.each( - httpStatusCases - )("does not treat HTTP $statusCode embedded in a longer number as a status token", ({ - statusCode, - }) => { - expect(inferUpstreamErrorStatusCodeFromText(`HTTP/1.1 ${statusCode}12`)).toBeNull(); - }); - - it.each( - httpStatusCases - )("does not treat HTTP $statusCode followed by a letter as a status token", ({ statusCode }) => { - expect(inferUpstreamErrorStatusCodeFromText(`HTTP/1.1 ${statusCode}abc`)).toBeNull(); - }); - - it.each(httpStatusCases)("keeps matching HTTP $statusCode followed by sentence punctuation", ({ - statusCode, - matcherId, - }) => { - expect(inferUpstreamErrorStatusCodeFromText(`HTTP/1.1 ${statusCode}.`)).toEqual({ - statusCode, - matcherId, - }); - }); - - it.each(cloudflareErrorCases)("keeps matching a standalone Cloudflare Error $code token", ({ - code, - statusCode, - matcherId, - }) => { - expect(inferUpstreamErrorStatusCodeFromText(`Error ${code}`)).toEqual({ - statusCode, - matcherId, - }); - }); - - it.each( - cloudflareErrorCases - )("does not treat Cloudflare Error $code followed by a decimal fraction as a code token", ({ - code, - }) => { - expect(inferUpstreamErrorStatusCodeFromText(`Error ${code}.7`)).toBeNull(); - }); - - it.each( - cloudflareErrorCases - )("does not treat Cloudflare Error $code embedded in a longer number as a code token", ({ - code, - }) => { - expect(inferUpstreamErrorStatusCodeFromText(`Error ${code}7`)).toBeNull(); - }); - - it.each( - cloudflareErrorCases - )("does not treat Cloudflare Error $code followed by a letter as a code token", ({ code }) => { - expect(inferUpstreamErrorStatusCodeFromText(`Error ${code}x`)).toBeNull(); - }); - - it.each( - cloudflareErrorCases - )("keeps matching Cloudflare Error $code followed by sentence punctuation", ({ - code, - statusCode, - matcherId, - }) => { - expect(inferUpstreamErrorStatusCodeFromText(`Error ${code}.`)).toEqual({ - statusCode, - matcherId, - }); - }); + it.each(httpStatusCases)( + "keeps matching a standalone HTTP $statusCode status token", + ({ statusCode, matcherId }) => { + expect(inferUpstreamErrorStatusCodeFromText(`HTTP/1.1 ${statusCode}`)).toEqual({ + statusCode, + matcherId, + }); + } + ); + + it.each(httpStatusCases)( + "does not treat HTTP $statusCode followed by a decimal fraction as a status token", + ({ statusCode }) => { + expect(inferUpstreamErrorStatusCodeFromText(`HTTP/1.1 ${statusCode}.12`)).toBeNull(); + } + ); + + it.each(httpStatusCases)( + "does not treat HTTP $statusCode embedded in a longer number as a status token", + ({ statusCode }) => { + expect(inferUpstreamErrorStatusCodeFromText(`HTTP/1.1 ${statusCode}12`)).toBeNull(); + } + ); + + it.each(httpStatusCases)( + "does not treat HTTP $statusCode followed by a letter as a status token", + ({ statusCode }) => { + expect(inferUpstreamErrorStatusCodeFromText(`HTTP/1.1 ${statusCode}abc`)).toBeNull(); + } + ); + + it.each(httpStatusCases)( + "keeps matching HTTP $statusCode followed by sentence punctuation", + ({ statusCode, matcherId }) => { + expect(inferUpstreamErrorStatusCodeFromText(`HTTP/1.1 ${statusCode}.`)).toEqual({ + statusCode, + matcherId, + }); + } + ); + + it.each(cloudflareErrorCases)( + "keeps matching a standalone Cloudflare Error $code token", + ({ code, statusCode, matcherId }) => { + expect(inferUpstreamErrorStatusCodeFromText(`Error ${code}`)).toEqual({ + statusCode, + matcherId, + }); + } + ); + + it.each(cloudflareErrorCases)( + "does not treat Cloudflare Error $code followed by a decimal fraction as a code token", + ({ code }) => { + expect(inferUpstreamErrorStatusCodeFromText(`Error ${code}.7`)).toBeNull(); + } + ); + + it.each(cloudflareErrorCases)( + "does not treat Cloudflare Error $code embedded in a longer number as a code token", + ({ code }) => { + expect(inferUpstreamErrorStatusCodeFromText(`Error ${code}7`)).toBeNull(); + } + ); + + it.each(cloudflareErrorCases)( + "does not treat Cloudflare Error $code followed by a letter as a code token", + ({ code }) => { + expect(inferUpstreamErrorStatusCodeFromText(`Error ${code}x`)).toBeNull(); + } + ); + + it.each(cloudflareErrorCases)( + "keeps matching Cloudflare Error $code followed by sentence punctuation", + ({ code, statusCode, matcherId }) => { + expect(inferUpstreamErrorStatusCodeFromText(`Error ${code}.`)).toEqual({ + statusCode, + matcherId, + }); + } + ); it("does not infer service_unavailable from an AWS request id containing 503", () => { const text = "request id: 202604250550399959"; diff --git a/tests/unit/proxy/client-detector.test.ts b/tests/unit/proxy/client-detector.test.ts index d14dabc6f..ee43c028e 100644 --- a/tests/unit/proxy/client-detector.test.ts +++ b/tests/unit/proxy/client-detector.test.ts @@ -85,13 +85,12 @@ describe("client-detector", () => { expect(isBuiltinKeyword(pattern)).toBe(true); }); - test.each([ - "gemini-cli", - "codex-cli", - "custom-pattern", - ])("should return false for non-builtin keyword: %s", (pattern) => { - expect(isBuiltinKeyword(pattern)).toBe(false); - }); + test.each(["gemini-cli", "codex-cli", "custom-pattern"])( + "should return false for non-builtin keyword: %s", + (pattern) => { + expect(isBuiltinKeyword(pattern)).toBe(false); + } + ); }); describe("confirmClaudeCodeSignals via detectClientFull", () => { diff --git a/tests/unit/proxy/codex-provider-overrides.test.ts b/tests/unit/proxy/codex-provider-overrides.test.ts index c233725b4..6f668bc45 100644 --- a/tests/unit/proxy/codex-provider-overrides.test.ts +++ b/tests/unit/proxy/codex-provider-overrides.test.ts @@ -259,35 +259,38 @@ describe("Codex 供应商级参数覆写", () => { ], }, ], - ])("当强制 image_generation=true 且%s已声明 namespace 时,allowed_tools 应使用同形引用", (_, request) => { - const provider = { - providerType: "codex", - codexImageGenerationPreference: "true", - }; - const input: Record = { - model: "gpt-5.5", - ...request, - tool_choice: { - type: "allowed_tools", - mode: "auto", - tools: [{ type: "function", name: "lookup_weather" }], - }, - }; + ])( + "当强制 image_generation=true 且%s已声明 namespace 时,allowed_tools 应使用同形引用", + (_, request) => { + const provider = { + providerType: "codex", + codexImageGenerationPreference: "true", + }; + const input: Record = { + model: "gpt-5.5", + ...request, + tool_choice: { + type: "allowed_tools", + mode: "auto", + tools: [{ type: "function", name: "lookup_weather" }], + }, + }; - const output = applyCodexProviderOverrides(provider as any, input); + const output = applyCodexProviderOverrides(provider as any, input); - expect(output.tool_choice).toEqual({ - type: "allowed_tools", - mode: "auto", - tools: [ - { type: "function", name: "lookup_weather" }, - { type: "namespace", name: "image_gen" }, - ], - }); - expect(output.tools).not.toEqual( - expect.arrayContaining([expect.objectContaining({ type: "image_generation" })]) - ); - }); + expect(output.tool_choice).toEqual({ + type: "allowed_tools", + mode: "auto", + tools: [ + { type: "function", name: "lookup_weather" }, + { type: "namespace", name: "image_gen" }, + ], + }); + expect(output.tools).not.toEqual( + expect.arrayContaining([expect.objectContaining({ type: "image_generation" })]) + ); + } + ); it("当强制 image_generation=false 时,应从 tools 中移除对应工具", () => { const provider = { @@ -425,27 +428,30 @@ describe("Codex 供应商级参数覆写", () => { ["字符串", "image_generation", "image_generation"], ["namespace 字段", { type: "namespace", namespace: "image_gen" }, "namespace:image_gen"], ["嵌套 tool", { tool: { type: "namespace", name: "image_gen" } }, "tool:image_generation"], - ])("当强制 image_generation=false 时,应移除%s形式的 tool_choice 并记录审计", (_, toolChoice, auditValue) => { - const provider = { - providerType: "codex", - codexImageGenerationPreference: "false", - }; - const input: Record = { - model: "gpt-5.5", - input: [], - tool_choice: toolChoice, - }; - - const result = applyCodexProviderOverridesWithAudit(provider as any, input); - - expect(result.request.tool_choice).toBeUndefined(); - expect(result.audit?.changes.find((change) => change.path === "tool_choice")).toEqual({ - path: "tool_choice", - before: auditValue, - after: null, - changed: true, - }); - }); + ])( + "当强制 image_generation=false 时,应移除%s形式的 tool_choice 并记录审计", + (_, toolChoice, auditValue) => { + const provider = { + providerType: "codex", + codexImageGenerationPreference: "false", + }; + const input: Record = { + model: "gpt-5.5", + input: [], + tool_choice: toolChoice, + }; + + const result = applyCodexProviderOverridesWithAudit(provider as any, input); + + expect(result.request.tool_choice).toBeUndefined(); + expect(result.audit?.changes.find((change) => change.path === "tool_choice")).toEqual({ + path: "tool_choice", + before: auditValue, + after: null, + changed: true, + }); + } + ); it("不应把名为 image_generation 的普通函数选择误判为内置图片工具", () => { const provider = { diff --git a/tests/unit/proxy/connected-non-reader-lifetime.test.ts b/tests/unit/proxy/connected-non-reader-lifetime.test.ts index 30862f7d9..f41e2a7c6 100644 --- a/tests/unit/proxy/connected-non-reader-lifetime.test.ts +++ b/tests/unit/proxy/connected-non-reader-lifetime.test.ts @@ -212,27 +212,27 @@ describe("connected non-reader response lifetime", () => { expect(settlements.every((settlement) => settlement.status === "fulfilled")).toBe(true); }); - it.each([ - true, - false, - ])("detaches client cancellation after headers with signal=%s", async (hasClientSignal) => { - const clientController = new AbortController(); - const session = await createGeminiSession(hasClientSignal ? clientController.signal : null); - let transportSignal: AbortSignal | undefined; - transportMocks.request.mockImplementation(async (_url, options) => { - transportSignal = options.signal; - return { - statusCode: 200, - headers: { "content-type": "text/event-stream" }, - body: Readable.from(["data: {}\n\n"]), - }; - }); + it.each([true, false])( + "detaches client cancellation after headers with signal=%s", + async (hasClientSignal) => { + const clientController = new AbortController(); + const session = await createGeminiSession(hasClientSignal ? clientController.signal : null); + let transportSignal: AbortSignal | undefined; + transportMocks.request.mockImplementation(async (_url, options) => { + transportSignal = options.signal; + return { + statusCode: 200, + headers: { "content-type": "text/event-stream" }, + body: Readable.from(["data: {}\n\n"]), + }; + }); - const response = await ProxyForwarder.send(session); - clientController.abort(new Error("client disconnected after headers")); - expect(transportSignal?.aborted).toBe(false); - await response.body?.cancel(); - }); + const response = await ProxyForwarder.send(session); + clientController.abort(new Error("client disconnected after headers")); + expect(transportSignal?.aborted).toBe(false); + await response.body?.cancel(); + } + ); it("detaches transport signals after an upstream error response", async () => { const clientController = new AbortController(); diff --git a/tests/unit/proxy/endpoint-family-catalog.test.ts b/tests/unit/proxy/endpoint-family-catalog.test.ts index 428fe937c..efd8ea69c 100644 --- a/tests/unit/proxy/endpoint-family-catalog.test.ts +++ b/tests/unit/proxy/endpoint-family-catalog.test.ts @@ -352,11 +352,12 @@ describe("endpoint family catalog", () => { expect(resolveEndpointFamilyByPath(path)?.modelRequired).toBe(true); }); - test.each(FAMILY_SAMPLES.filter((entry) => !entry.modelRequired))("%s 不应要求模型", ({ - path, - }) => { - expect(resolveEndpointFamilyByPath(path)?.modelRequired).toBe(false); - }); + test.each(FAMILY_SAMPLES.filter((entry) => !entry.modelRequired))( + "%s 不应要求模型", + ({ path }) => { + expect(resolveEndpointFamilyByPath(path)?.modelRequired).toBe(false); + } + ); test("Gemini batch body fallback 应识别为 gemini", () => { expect( diff --git a/tests/unit/proxy/endpoint-family-provider-routing.test.ts b/tests/unit/proxy/endpoint-family-provider-routing.test.ts index 14d121fb1..4722353fd 100644 --- a/tests/unit/proxy/endpoint-family-provider-routing.test.ts +++ b/tests/unit/proxy/endpoint-family-provider-routing.test.ts @@ -385,32 +385,31 @@ describe("endpoint family -> provider routing matrix", () => { ); }); - test.each(ENDPOINT_PROVIDER_CASES)("$id should route $path to $expectedProviderType", async ({ - path, - expectedProviderType, - requestedModel, - }) => { - const ProxyProviderResolver = await setupResolverMocks(); + test.each(ENDPOINT_PROVIDER_CASES)( + "$id should route $path to $expectedProviderType", + async ({ path, expectedProviderType, requestedModel }) => { + const ProxyProviderResolver = await setupResolverMocks(); - const providers: Provider[] = [ - createTestProvider(1, "claude"), - createTestProvider(2, "claude-auth"), - createTestProvider(3, "codex"), - createTestProvider(4, "openai-compatible"), - createTestProvider(5, "gemini"), - createTestProvider(6, "gemini-cli"), - ]; - const session = createSessionStub(path, requestedModel); - session.getProvidersSnapshot = async () => providers; + const providers: Provider[] = [ + createTestProvider(1, "claude"), + createTestProvider(2, "claude-auth"), + createTestProvider(3, "codex"), + createTestProvider(4, "openai-compatible"), + createTestProvider(5, "gemini"), + createTestProvider(6, "gemini-cli"), + ]; + const session = createSessionStub(path, requestedModel); + session.getProvidersSnapshot = async () => providers; - const { provider, context } = await (ProxyProviderResolver as any).pickRandomProvider( - session, - [] - ); + const { provider, context } = await (ProxyProviderResolver as any).pickRandomProvider( + session, + [] + ); - expect(provider?.providerType).toBe(expectedProviderType); - expect(context.requestedModel).toBe(requestedModel); - }); + expect(provider?.providerType).toBe(expectedProviderType); + expect(context.requestedModel).toBe(requestedModel); + } + ); test("/v1/chat/completions should never select codex when openai-compatible is available", async () => { const ProxyProviderResolver = await setupResolverMocks(); diff --git a/tests/unit/proxy/endpoint-path-normalization.test.ts b/tests/unit/proxy/endpoint-path-normalization.test.ts index 8b4662e04..183585a7b 100644 --- a/tests/unit/proxy/endpoint-path-normalization.test.ts +++ b/tests/unit/proxy/endpoint-path-normalization.test.ts @@ -38,17 +38,15 @@ describe("endpoint path normalization", () => { expect(isRawPassthroughEndpointPath(pathname)).toBe(true); }); - test.each([ - "/v1/messages", - "/v1/responses", - "/v1/messages/count", - "/v1/responses/mini", - ])("non-target path is not misclassified for %s", (pathname) => { - expect(isCountTokensEndpointPath(pathname)).toBe(false); - expect(isResponseCompactEndpointPath(pathname)).toBe(false); - expect(isRawPassthroughEndpointPath(pathname)).toBe(false); - expect(isCountTokensRequestWithEndpoint(pathname)).toBe(false); - }); + test.each(["/v1/messages", "/v1/responses", "/v1/messages/count", "/v1/responses/mini"])( + "non-target path is not misclassified for %s", + (pathname) => { + expect(isCountTokensEndpointPath(pathname)).toBe(false); + expect(isResponseCompactEndpointPath(pathname)).toBe(false); + expect(isRawPassthroughEndpointPath(pathname)).toBe(false); + expect(isCountTokensRequestWithEndpoint(pathname)).toBe(false); + } + ); test("session count_tokens detection handles null endpoint", () => { expect(isCountTokensRequestWithEndpoint(null)).toBe(false); diff --git a/tests/unit/proxy/error-handler-terminal-status.test.ts b/tests/unit/proxy/error-handler-terminal-status.test.ts index ed5350590..98b2d5a60 100644 --- a/tests/unit/proxy/error-handler-terminal-status.test.ts +++ b/tests/unit/proxy/error-handler-terminal-status.test.ts @@ -159,36 +159,43 @@ describe("ProxyErrorHandler.handle terminal status", () => { ); }); - test.each(RATE_LIMIT_CASES)("maps $limitType limits to HTTP $expectedStatus", async ({ - limitType, - expectedStatus, - }) => { - const session = await createSession(); - const error = new RateLimitError("rate_limit_error", "limit exceeded", limitType, 12, 20, null); - - const response = await ProxyErrorHandler.handle(session, error); - - expect(response.status).toBe(expectedStatus); - expect(await response.json()).toEqual({ - error: { - type: "rate_limit_error", - message: "limit exceeded", - code: "rate_limit_exceeded", - limit_type: limitType, - current: 12, - limit: 20, - reset_time: null, - }, - }); - expect(mocks.emitProxyLangfuseTrace).toHaveBeenCalledWith( - session, - expect.objectContaining({ - responseText: "", - statusCode: expectedStatus, - errorMessage: "limit exceeded", - }) - ); - }); + test.each(RATE_LIMIT_CASES)( + "maps $limitType limits to HTTP $expectedStatus", + async ({ limitType, expectedStatus }) => { + const session = await createSession(); + const error = new RateLimitError( + "rate_limit_error", + "limit exceeded", + limitType, + 12, + 20, + null + ); + + const response = await ProxyErrorHandler.handle(session, error); + + expect(response.status).toBe(expectedStatus); + expect(await response.json()).toEqual({ + error: { + type: "rate_limit_error", + message: "limit exceeded", + code: "rate_limit_exceeded", + limit_type: limitType, + current: 12, + limit: 20, + reset_time: null, + }, + }); + expect(mocks.emitProxyLangfuseTrace).toHaveBeenCalledWith( + session, + expect.objectContaining({ + responseText: "", + statusCode: expectedStatus, + errorMessage: "limit exceeded", + }) + ); + } + ); test("keeps fixed-window rate-limit headers", async () => { const session = await createSession(); diff --git a/tests/unit/proxy/fake-streaming-response-validator.test.ts b/tests/unit/proxy/fake-streaming-response-validator.test.ts index 83b33deba..310f45b0c 100644 --- a/tests/unit/proxy/fake-streaming-response-validator.test.ts +++ b/tests/unit/proxy/fake-streaming-response-validator.test.ts @@ -15,64 +15,54 @@ function failure(family: ProtocolFamily, body: string, isStream: boolean, status describe("validateUpstreamResponse", () => { describe("status code handling", () => { - test.each([ - "anthropic", - "openai-chat", - "openai-responses", - "gemini", - ])("%s: non-2xx is failure regardless of body", (family) => { - const valid = `{"id":"ok","model":"m","content":[{"type":"text","text":"hi"}]}`; - expect(failure(family, valid, false, 500).ok).toBe(false); - expect(failure(family, valid, false, 502).ok).toBe(false); - expect(failure(family, valid, false, 429).ok).toBe(false); - expect(failure(family, valid, false, 401).ok).toBe(false); - }); + test.each(["anthropic", "openai-chat", "openai-responses", "gemini"])( + "%s: non-2xx is failure regardless of body", + (family) => { + const valid = `{"id":"ok","model":"m","content":[{"type":"text","text":"hi"}]}`; + expect(failure(family, valid, false, 500).ok).toBe(false); + expect(failure(family, valid, false, 502).ok).toBe(false); + expect(failure(family, valid, false, 429).ok).toBe(false); + expect(failure(family, valid, false, 401).ok).toBe(false); + } + ); }); describe("empty / whitespace bodies", () => { - test.each([ - "anthropic", - "openai-chat", - "openai-responses", - "gemini", - ])("%s: empty body fails (non-stream)", (family) => { - expect(failure(family, "", false).ok).toBe(false); - expect(failure(family, " ", false).ok).toBe(false); - expect(failure(family, "\n\n \t\n", false).ok).toBe(false); - }); + test.each(["anthropic", "openai-chat", "openai-responses", "gemini"])( + "%s: empty body fails (non-stream)", + (family) => { + expect(failure(family, "", false).ok).toBe(false); + expect(failure(family, " ", false).ok).toBe(false); + expect(failure(family, "\n\n \t\n", false).ok).toBe(false); + } + ); - test.each([ - "anthropic", - "openai-chat", - "openai-responses", - "gemini", - ])("%s: empty body fails (stream)", (family) => { - expect(failure(family, "", true).ok).toBe(false); - expect(failure(family, " ", true).ok).toBe(false); - }); + test.each(["anthropic", "openai-chat", "openai-responses", "gemini"])( + "%s: empty body fails (stream)", + (family) => { + expect(failure(family, "", true).ok).toBe(false); + expect(failure(family, " ", true).ok).toBe(false); + } + ); }); describe("invalid JSON for non-stream", () => { - test.each([ - "anthropic", - "openai-chat", - "openai-responses", - "gemini", - ])("%s: invalid JSON fails non-stream", (family) => { - expect(failure(family, "not-json", false).ok).toBe(false); - expect(failure(family, "{ truncated", false).ok).toBe(false); - }); + test.each(["anthropic", "openai-chat", "openai-responses", "gemini"])( + "%s: invalid JSON fails non-stream", + (family) => { + expect(failure(family, "not-json", false).ok).toBe(false); + expect(failure(family, "{ truncated", false).ok).toBe(false); + } + ); }); describe("SSE failure cases", () => { - test.each([ - "anthropic", - "openai-chat", - "openai-responses", - "gemini", - ])("%s: comment-only SSE fails", (family) => { - expect(failure(family, ": ping\n\n: ping\n\n", true).ok).toBe(false); - }); + test.each(["anthropic", "openai-chat", "openai-responses", "gemini"])( + "%s: comment-only SSE fails", + (family) => { + expect(failure(family, ": ping\n\n: ping\n\n", true).ok).toBe(false); + } + ); test("openai-chat: [DONE]-only SSE fails", () => { expect(failure("openai-chat", "data: [DONE]\n\n", true).ok).toBe(false); diff --git a/tests/unit/proxy/fake-streaming-response.test.ts b/tests/unit/proxy/fake-streaming-response.test.ts index 88cad4aa6..bc6350f76 100644 --- a/tests/unit/proxy/fake-streaming-response.test.ts +++ b/tests/unit/proxy/fake-streaming-response.test.ts @@ -40,15 +40,13 @@ function parseSseEvents(body: string): Array<{ event: string | null; data: strin } describe("emitFinalNonStream", () => { - test.each([ - "anthropic", - "openai-chat", - "openai-responses", - "gemini", - ])("%s: returns the validated final body verbatim", (family) => { - const body = JSON.stringify({ id: "x", model: "m", content: [{ type: "text", text: "hi" }] }); - expect(emitFinalNonStream({ family, finalBody: body })).toBe(body); - }); + test.each(["anthropic", "openai-chat", "openai-responses", "gemini"])( + "%s: returns the validated final body verbatim", + (family) => { + const body = JSON.stringify({ id: "x", model: "m", content: [{ type: "text", text: "hi" }] }); + expect(emitFinalNonStream({ family, finalBody: body })).toBe(body); + } + ); }); describe("emitFinalStream — anthropic", () => { diff --git a/tests/unit/proxy/fake-streaming-stream-intent.test.ts b/tests/unit/proxy/fake-streaming-stream-intent.test.ts index 75f65067e..f4b623048 100644 --- a/tests/unit/proxy/fake-streaming-stream-intent.test.ts +++ b/tests/unit/proxy/fake-streaming-stream-intent.test.ts @@ -26,33 +26,31 @@ function inputs({ describe("detectClientStreamIntent", () => { describe("standard formats (claude / openai / response)", () => { - test.each([ - "claude", - "openai", - "response", - ])("%s: body.stream === true => stream", (format) => { - expect( - detectClientStreamIntent( - inputs({ format, pathname: "/v1/messages", body: { stream: true } }) - ) - ).toBe(true); - }); + test.each(["claude", "openai", "response"])( + "%s: body.stream === true => stream", + (format) => { + expect( + detectClientStreamIntent( + inputs({ format, pathname: "/v1/messages", body: { stream: true } }) + ) + ).toBe(true); + } + ); - test.each([ - "claude", - "openai", - "response", - ])("%s: body.stream missing or false => non-stream", (format) => { - expect( - detectClientStreamIntent( - inputs({ format, pathname: "/v1/messages", body: { stream: false } }) - ) - ).toBe(false); - expect(detectClientStreamIntent(inputs({ format, pathname: "/v1/messages", body: {} }))).toBe( - false - ); - expect(detectClientStreamIntent(inputs({ format, pathname: "/v1/messages" }))).toBe(false); - }); + test.each(["claude", "openai", "response"])( + "%s: body.stream missing or false => non-stream", + (format) => { + expect( + detectClientStreamIntent( + inputs({ format, pathname: "/v1/messages", body: { stream: false } }) + ) + ).toBe(false); + expect( + detectClientStreamIntent(inputs({ format, pathname: "/v1/messages", body: {} })) + ).toBe(false); + expect(detectClientStreamIntent(inputs({ format, pathname: "/v1/messages" }))).toBe(false); + } + ); test("standard formats ignore path / query for stream intent", () => { expect( @@ -69,20 +67,20 @@ describe("detectClientStreamIntent", () => { }); describe("gemini family", () => { - test.each([ - "gemini", - "gemini-cli", - ])("%s: streamGenerateContent in path => stream", (format) => { - expect( - detectClientStreamIntent( - inputs({ - format, - pathname: "/v1beta/models/gemini-1.5-pro:streamGenerateContent", - body: {}, - }) - ) - ).toBe(true); - }); + test.each(["gemini", "gemini-cli"])( + "%s: streamGenerateContent in path => stream", + (format) => { + expect( + detectClientStreamIntent( + inputs({ + format, + pathname: "/v1beta/models/gemini-1.5-pro:streamGenerateContent", + body: {}, + }) + ) + ).toBe(true); + } + ); test.each(["gemini", "gemini-cli"])("%s: alt=sse query => stream", (format) => { expect( @@ -97,43 +95,43 @@ describe("detectClientStreamIntent", () => { ).toBe(true); }); - test.each([ - "gemini", - "gemini-cli", - ])("%s: body.stream === true => stream", (format) => { - expect( - detectClientStreamIntent( - inputs({ - format, - pathname: "/v1beta/models/gemini-1.5-pro:generateContent", - body: { stream: true }, - }) - ) - ).toBe(true); - }); + test.each(["gemini", "gemini-cli"])( + "%s: body.stream === true => stream", + (format) => { + expect( + detectClientStreamIntent( + inputs({ + format, + pathname: "/v1beta/models/gemini-1.5-pro:generateContent", + body: { stream: true }, + }) + ) + ).toBe(true); + } + ); - test.each([ - "gemini", - "gemini-cli", - ])("%s: no streaming signal => non-stream", (format) => { - expect( - detectClientStreamIntent( - inputs({ - format, - pathname: "/v1beta/models/gemini-1.5-pro:generateContent", - body: { stream: false }, - }) - ) - ).toBe(false); - expect( - detectClientStreamIntent( - inputs({ - format, - pathname: "/v1beta/models/gemini-1.5-pro:generateContent", - }) - ) - ).toBe(false); - }); + test.each(["gemini", "gemini-cli"])( + "%s: no streaming signal => non-stream", + (format) => { + expect( + detectClientStreamIntent( + inputs({ + format, + pathname: "/v1beta/models/gemini-1.5-pro:generateContent", + body: { stream: false }, + }) + ) + ).toBe(false); + expect( + detectClientStreamIntent( + inputs({ + format, + pathname: "/v1beta/models/gemini-1.5-pro:generateContent", + }) + ) + ).toBe(false); + } + ); test("gemini search supports object form", () => { expect( diff --git a/tests/unit/proxy/provider-selector-cross-type-model.test.ts b/tests/unit/proxy/provider-selector-cross-type-model.test.ts index e883463dc..94eaec709 100644 --- a/tests/unit/proxy/provider-selector-cross-type-model.test.ts +++ b/tests/unit/proxy/provider-selector-cross-type-model.test.ts @@ -198,21 +198,18 @@ describe("providerSupportsModel - direct unit tests (#832)", () => { }, ]; - test.each(cases)("$name", async ({ - providerType, - allowedModels, - modelRedirects, - requestedModel, - expected, - }) => { - const { providerSupportsModel } = await import("@/app/v1/_lib/proxy/provider-selector"); - const provider = createProvider({ - providerType, - allowedModels, - ...(modelRedirects && { modelRedirects }), - }); - expect(providerSupportsModel(provider, requestedModel)).toBe(expected); - }); + test.each(cases)( + "$name", + async ({ providerType, allowedModels, modelRedirects, requestedModel, expected }) => { + const { providerSupportsModel } = await import("@/app/v1/_lib/proxy/provider-selector"); + const provider = createProvider({ + providerType, + allowedModels, + ...(modelRedirects && { modelRedirects }), + }); + expect(providerSupportsModel(provider, requestedModel)).toBe(expected); + } + ); }); // ══════════════════════════════════════════════════════════════════ diff --git a/tests/unit/proxy/proxy-forwarder-endpoint-audit.test.ts b/tests/unit/proxy/proxy-forwarder-endpoint-audit.test.ts index 51610de69..a02dc05a2 100644 --- a/tests/unit/proxy/proxy-forwarder-endpoint-audit.test.ts +++ b/tests/unit/proxy/proxy-forwarder-endpoint-audit.test.ts @@ -418,68 +418,69 @@ describe("ProxyForwarder - endpoint audit", () => { test.each([ { requestPath: "/v1/messages/count_tokens", providerType: "claude" as const }, { requestPath: "/v1/responses/compact", providerType: "codex" as const }, - ])("raw 端点 $requestPath: endpoint 选择失败时不应静默回退到 provider.url", async ({ - requestPath, - providerType, - }) => { - const session = createSession(new URL(`https://example.com${requestPath}`)); - const provider = createProvider({ - providerType, - providerVendorId: 123, - url: `https://provider.example.com${requestPath}?key=SECRET`, - }); - session.setProvider(provider); + ])( + "raw 端点 $requestPath: endpoint 选择失败时不应静默回退到 provider.url", + async ({ requestPath, providerType }) => { + const session = createSession(new URL(`https://example.com${requestPath}`)); + const provider = createProvider({ + providerType, + providerVendorId: 123, + url: `https://provider.example.com${requestPath}?key=SECRET`, + }); + session.setProvider(provider); - mocks.getPreferredProviderEndpoints.mockRejectedValueOnce(new Error("boom")); + mocks.getPreferredProviderEndpoints.mockRejectedValueOnce(new Error("boom")); - const doForward = vi.spyOn( - ProxyForwarder as unknown as { doForward: (...args: unknown[]) => unknown }, - "doForward" - ); - doForward.mockResolvedValueOnce( - new Response("{}", { - status: 200, - headers: { - "content-type": "application/json", - "content-length": "2", - }, - }) - ); + const doForward = vi.spyOn( + ProxyForwarder as unknown as { doForward: (...args: unknown[]) => unknown }, + "doForward" + ); + doForward.mockResolvedValueOnce( + new Response("{}", { + status: 200, + headers: { + "content-type": "application/json", + "content-length": "2", + }, + }) + ); - const rejected = await ProxyForwarder.send(session) - .then(() => false) - .catch(() => true); + const rejected = await ProxyForwarder.send(session) + .then(() => false) + .catch(() => true); - expect(rejected, `raw 端点 ${requestPath} endpoint 选择失败后不允许静默回退 provider.url`).toBe( - true - ); - expect(doForward).not.toHaveBeenCalled(); + expect( + rejected, + `raw 端点 ${requestPath} endpoint 选择失败后不允许静默回退 provider.url` + ).toBe(true); + expect(doForward).not.toHaveBeenCalled(); - expect(logger.warn).toHaveBeenCalledWith( - "[ProxyForwarder] Failed to load provider endpoints", - expect.objectContaining({ - providerId: provider.id, - vendorId: 123, - providerType, - strictEndpointPolicy: true, - reason: "selector_error", - error: "boom", - }) - ); + expect(logger.warn).toHaveBeenCalledWith( + "[ProxyForwarder] Failed to load provider endpoints", + expect.objectContaining({ + providerId: provider.id, + vendorId: 123, + providerType, + strictEndpointPolicy: true, + reason: "selector_error", + error: "boom", + }) + ); - expect(logger.warn).toHaveBeenCalledWith( - "ProxyForwarder: Strict endpoint policy blocked legacy provider.url fallback", - expect.objectContaining({ - providerId: provider.id, - vendorId: 123, - providerType, - requestPath, - reason: "strict_blocked_legacy_fallback", - strictBlockCause: "selector_error", - selectorError: "boom", - }) - ); - }); + expect(logger.warn).toHaveBeenCalledWith( + "ProxyForwarder: Strict endpoint policy blocked legacy provider.url fallback", + expect.objectContaining({ + providerId: provider.id, + vendorId: 123, + providerType, + requestPath, + reason: "strict_blocked_legacy_fallback", + strictBlockCause: "selector_error", + selectorError: "boom", + }) + ); + } + ); test("raw 端点空候选应记录 no_endpoint_candidates 且不混淆为 selector_error", async () => { const requestPath = "/v1/messages/count_tokens"; diff --git a/tests/unit/proxy/proxy-forwarder-hedge-first-byte.test.ts b/tests/unit/proxy/proxy-forwarder-hedge-first-byte.test.ts index dd95e53fc..3bc0b6c23 100644 --- a/tests/unit/proxy/proxy-forwarder-hedge-first-byte.test.ts +++ b/tests/unit/proxy/proxy-forwarder-hedge-first-byte.test.ts @@ -477,52 +477,52 @@ describe("ProxyForwarder - first-byte hedge scheduling", () => { expect(mocks.acquireSessionDiscoveryLease).toHaveBeenCalledTimes(1); }); - test.each([ - "unknown", - "unavailable", - ] as const)("Discovery fails closed when the binding capability probe returns %s", async (capabilityState) => { - const provider = createProvider({ id: 1 }); - const session = createSession(); - session.authState = { - success: true, - user: null, - key: { id: 21 }, - apiKey: null, - } as typeof session.authState; - session.setProvider(provider); - mocks.ensureVersionedBindingCapability.mockResolvedValueOnce(capabilityState); + test.each(["unknown", "unavailable"] as const)( + "Discovery fails closed when the binding capability probe returns %s", + async (capabilityState) => { + const provider = createProvider({ id: 1 }); + const session = createSession(); + session.authState = { + success: true, + user: null, + key: { id: 21 }, + apiKey: null, + } as typeof session.authState; + session.setProvider(provider); + mocks.ensureVersionedBindingCapability.mockResolvedValueOnce(capabilityState); - const prepareStreamingDiscovery = ( - ProxyForwarder as unknown as { - prepareStreamingDiscovery: ( - session: ProxySession, - settings: SystemSettings, - requestStartedAt: number - ) => Promise; - } - ).prepareStreamingDiscovery; - const prepared = await prepareStreamingDiscovery( - session, - { - discoveryEnabled: true, - discoveryConcurrency: 2, - maxDiscoveryRounds: 1, - discoverySlaMs: 50, - stickySlaMs: 50, - racingTotalTimeoutMs: 200, - stickyTimeoutCooldownMs: 300_000, - } as SystemSettings, - Date.now() - ); + const prepareStreamingDiscovery = ( + ProxyForwarder as unknown as { + prepareStreamingDiscovery: ( + session: ProxySession, + settings: SystemSettings, + requestStartedAt: number + ) => Promise; + } + ).prepareStreamingDiscovery; + const prepared = await prepareStreamingDiscovery( + session, + { + discoveryEnabled: true, + discoveryConcurrency: 2, + maxDiscoveryRounds: 1, + discoverySlaMs: 50, + stickySlaMs: 50, + racingTotalTimeoutMs: 200, + stickyTimeoutCooldownMs: 300_000, + } as SystemSettings, + Date.now() + ); - expect(prepared).toEqual({ - status: "skipped", - reason: "redis_capability_unavailable", - }); - expect(mocks.ensureVersionedBindingCapability).toHaveBeenCalledTimes(1); - expect(mocks.getSessionBindingSnapshot).not.toHaveBeenCalled(); - expect(mocks.acquireSessionDiscoveryLease).not.toHaveBeenCalled(); - }); + expect(prepared).toEqual({ + status: "skipped", + reason: "redis_capability_unavailable", + }); + expect(mocks.ensureVersionedBindingCapability).toHaveBeenCalledTimes(1); + expect(mocks.getSessionBindingSnapshot).not.toHaveBeenCalled(); + expect(mocks.acquireSessionDiscoveryLease).not.toHaveBeenCalled(); + } + ); test("shadow session redirect should not overwrite initial provider redirect and winner should keep its own redirect", () => { const requestedModel = "claude-haiku-4-5-20251001"; @@ -1976,82 +1976,86 @@ describe("ProxyForwarder - first-byte hedge scheduling", () => { category: ProxyErrorCategory.SYSTEM_ERROR, errorFactory: () => new Error("fetch failed"), }, - ])("when a real hedge race ends with only $name, terminal error should be generic fallback", async ({ - category, - errorFactory, - }) => { - vi.useFakeTimers(); - - try { - const provider1 = createProvider({ - id: 1, - name: "p1", - firstByteTimeoutStreamingMs: 100, - }); - const provider2 = createProvider({ - id: 2, - name: "p2", - firstByteTimeoutStreamingMs: 100, - }); - const session = createSession(); - session.setProvider(provider1); + ])( + "when a real hedge race ends with only $name, terminal error should be generic fallback", + async ({ category, errorFactory }) => { + vi.useFakeTimers(); + + try { + const provider1 = createProvider({ + id: 1, + name: "p1", + firstByteTimeoutStreamingMs: 100, + }); + const provider2 = createProvider({ + id: 2, + name: "p2", + firstByteTimeoutStreamingMs: 100, + }); + const session = createSession(); + session.setProvider(provider1); - mocks.pickRandomProviderWithExclusion - .mockResolvedValueOnce(provider2) - .mockResolvedValueOnce(null); - mocks.categorizeErrorAsync.mockResolvedValueOnce(category).mockResolvedValueOnce(category); + mocks.pickRandomProviderWithExclusion + .mockResolvedValueOnce(provider2) + .mockResolvedValueOnce(null); + mocks.categorizeErrorAsync.mockResolvedValueOnce(category).mockResolvedValueOnce(category); - const doForward = vi.spyOn( - ProxyForwarder as unknown as { - doForward: (...args: unknown[]) => Promise; - }, - "doForward" - ); - - const controller1 = new AbortController(); - const controller2 = new AbortController(); + const doForward = vi.spyOn( + ProxyForwarder as unknown as { + doForward: (...args: unknown[]) => Promise; + }, + "doForward" + ); - doForward.mockImplementationOnce(async (attemptSession) => { - const runtime = attemptSession as ProxySession & AttemptRuntime; - runtime.responseController = controller1; - runtime.clearResponseTimeout = vi.fn(); - return createDelayedFailure({ - delayMs: 150, - error: errorFactory(provider1), - controller: controller1, + const controller1 = new AbortController(); + const controller2 = new AbortController(); + + doForward.mockImplementationOnce(async (attemptSession) => { + const runtime = attemptSession as ProxySession & AttemptRuntime; + runtime.responseController = controller1; + runtime.clearResponseTimeout = vi.fn(); + return createDelayedFailure({ + delayMs: 150, + error: errorFactory(provider1), + controller: controller1, + }); }); - }); - doForward.mockImplementationOnce(async (attemptSession) => { - const runtime = attemptSession as ProxySession & AttemptRuntime; - runtime.responseController = controller2; - runtime.clearResponseTimeout = vi.fn(); - return createDelayedFailure({ - delayMs: 160, - error: errorFactory(provider2), - controller: controller2, + doForward.mockImplementationOnce(async (attemptSession) => { + const runtime = attemptSession as ProxySession & AttemptRuntime; + runtime.responseController = controller2; + runtime.clearResponseTimeout = vi.fn(); + return createDelayedFailure({ + delayMs: 160, + error: errorFactory(provider2), + controller: controller2, + }); }); - }); - const responsePromise = ProxyForwarder.send(session); - const errorPromise = responsePromise.catch((rejection) => rejection as UpstreamProxyError); + const responsePromise = ProxyForwarder.send(session); + const errorPromise = responsePromise.catch((rejection) => rejection as UpstreamProxyError); - await vi.advanceTimersByTimeAsync(100); - expect(doForward).toHaveBeenCalledTimes(2); + await vi.advanceTimersByTimeAsync(100); + expect(doForward).toHaveBeenCalledTimes(2); - await vi.runAllTimersAsync(); - const error = await errorPromise; + await vi.runAllTimersAsync(); + const error = await errorPromise; - expect(error).toBeInstanceOf(UpstreamProxyError); - expect(error.statusCode).toBe(503); - expect(error.message).toBe("所有供应商暂时不可用,请稍后重试"); - expect(error.message).not.toContain("invalid key"); - expect(error.message).not.toContain("model not found"); - expect(mocks.clearSessionProviders).toHaveBeenCalledWith("sess-hedge", new Set([1, 2]), null); - } finally { - vi.useRealTimers(); + expect(error).toBeInstanceOf(UpstreamProxyError); + expect(error.statusCode).toBe(503); + expect(error.message).toBe("所有供应商暂时不可用,请稍后重试"); + expect(error.message).not.toContain("invalid key"); + expect(error.message).not.toContain("model not found"); + expect(mocks.clearSessionProviders).toHaveBeenCalledWith( + "sess-hedge", + new Set([1, 2]), + null + ); + } finally { + vi.useRealTimers(); + } } - }); + ); test("non-retryable client errors should stop hedge immediately and preserve original error", async () => { const provider1 = createProvider({ diff --git a/tests/unit/proxy/proxy-forwarder-retry-limit.test.ts b/tests/unit/proxy/proxy-forwarder-retry-limit.test.ts index e5b8dea79..ff51303ba 100644 --- a/tests/unit/proxy/proxy-forwarder-retry-limit.test.ts +++ b/tests/unit/proxy/proxy-forwarder-retry-limit.test.ts @@ -243,64 +243,64 @@ describe("ProxyForwarder - raw passthrough fallback parity", () => { vi.mocked(categorizeErrorAsync).mockResolvedValue(ErrorCategory.PROVIDER_ERROR); }); - test.each([ - V1_ENDPOINT_PATHS.MESSAGES_COUNT_TOKENS, - V1_ENDPOINT_PATHS.RESPONSES_COMPACT, - ])("%s 失败时应允许跨 provider fallback,但仍保持 no-circuit", async (pathname) => { - vi.useFakeTimers(); - - try { - const session = createSession(new URL(`https://example.com${pathname}`)); - const provider = createProvider({ - providerType: "claude", - providerVendorId: 123, - maxRetryAttempts: 3, - }); - session.setProvider(provider); - - mocks.getPreferredProviderEndpoints.mockResolvedValue([ - makeEndpoint({ - id: 1, - vendorId: 123, - providerType: "claude", - url: "https://ep1.example.com", - }), - makeEndpoint({ - id: 2, - vendorId: 123, + test.each([V1_ENDPOINT_PATHS.MESSAGES_COUNT_TOKENS, V1_ENDPOINT_PATHS.RESPONSES_COMPACT])( + "%s 失败时应允许跨 provider fallback,但仍保持 no-circuit", + async (pathname) => { + vi.useFakeTimers(); + + try { + const session = createSession(new URL(`https://example.com${pathname}`)); + const provider = createProvider({ providerType: "claude", - url: "https://ep2.example.com", - }), - ]); - - const doForward = vi.spyOn( - ProxyForwarder as unknown as { doForward: (...args: unknown[]) => unknown }, - "doForward" - ); - const selectAlternative = vi.spyOn( - ProxyForwarder as unknown as { selectAlternative: (...args: unknown[]) => unknown }, - "selectAlternative" - ); - - doForward.mockImplementation(async () => { - throw new ProxyError("upstream failed", 500); - }); - - const sendPromise = ProxyForwarder.send(session); - let caughtError: Error | null = null; - sendPromise.catch((error) => { - caughtError = error as Error; - }); - await vi.runAllTimersAsync(); + providerVendorId: 123, + maxRetryAttempts: 3, + }); + session.setProvider(provider); + + mocks.getPreferredProviderEndpoints.mockResolvedValue([ + makeEndpoint({ + id: 1, + vendorId: 123, + providerType: "claude", + url: "https://ep1.example.com", + }), + makeEndpoint({ + id: 2, + vendorId: 123, + providerType: "claude", + url: "https://ep2.example.com", + }), + ]); + + const doForward = vi.spyOn( + ProxyForwarder as unknown as { doForward: (...args: unknown[]) => unknown }, + "doForward" + ); + const selectAlternative = vi.spyOn( + ProxyForwarder as unknown as { selectAlternative: (...args: unknown[]) => unknown }, + "selectAlternative" + ); + + doForward.mockImplementation(async () => { + throw new ProxyError("upstream failed", 500); + }); - expect(caughtError).toBeInstanceOf(ProxyError); - expect(doForward).toHaveBeenCalledTimes(1); - expect(selectAlternative).toHaveBeenCalledTimes(1); - expect(mocks.recordFailure).not.toHaveBeenCalled(); - } finally { - vi.useRealTimers(); + const sendPromise = ProxyForwarder.send(session); + let caughtError: Error | null = null; + sendPromise.catch((error) => { + caughtError = error as Error; + }); + await vi.runAllTimersAsync(); + + expect(caughtError).toBeInstanceOf(ProxyError); + expect(doForward).toHaveBeenCalledTimes(1); + expect(selectAlternative).toHaveBeenCalledTimes(1); + expect(mocks.recordFailure).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } } - }); + ); }); describe("ProxyForwarder - retry limit enforcement", () => { diff --git a/tests/unit/proxy/response-handler-client-abort-drain.test.ts b/tests/unit/proxy/response-handler-client-abort-drain.test.ts index 71a03eb45..447864632 100644 --- a/tests/unit/proxy/response-handler-client-abort-drain.test.ts +++ b/tests/unit/proxy/response-handler-client-abort-drain.test.ts @@ -2117,65 +2117,67 @@ describe("ProxyResponseHandler stream client abort finalization", () => { it.each([ { bindingIntent: "create" as const, providerId: null }, { bindingIntent: "renew" as const, providerId: 1 }, - ])("preserves binding state for a client-aborted Discovery $bindingIntent stream", async ({ - bindingIntent, - providerId, - }) => { - const controller = new AbortController(); - controller.abort(); - const session = createSession(controller.signal); - Object.assign(session, { - sessionId: `session-client-abort-${bindingIntent}`, - }); - session.recordProviderSessionRef(1); - vi.mocked(SessionManager.extractCodexPromptCacheKey).mockReturnValue("client-abort-cache-key"); - setDeferredStreamingFinalization(session, { - providerId: 1, - providerName: "avemujica-responses", - providerPriority: 1, - attemptNumber: 1, - totalProvidersAttempted: 2, - isFirstAttempt: false, - isFailoverSuccess: bindingIntent === "create", - endpointId: 42, - endpointUrl: "https://api.test.invalid/v1", - upstreamStatusCode: 200, - bindingIntent, - bindingSnapshot: { - sessionId: `session-client-abort-${bindingIntent}`, - keyId: 2, - providerId, - generation: `${bindingIntent}-generation`, - }, - requiresCompletionMarkerForBinding: true, - discoveryLease: { + ])( + "preserves binding state for a client-aborted Discovery $bindingIntent stream", + async ({ bindingIntent, providerId }) => { + const controller = new AbortController(); + controller.abort(); + const session = createSession(controller.signal); + Object.assign(session, { sessionId: `session-client-abort-${bindingIntent}`, - keyId: 2, - ownerToken: `client-abort-${bindingIntent}-owner`, - ttlSeconds: 30, - }, - providerSessionRefOwned: true, - }); + }); + session.recordProviderSessionRef(1); + vi.mocked(SessionManager.extractCodexPromptCacheKey).mockReturnValue( + "client-abort-cache-key" + ); + setDeferredStreamingFinalization(session, { + providerId: 1, + providerName: "avemujica-responses", + providerPriority: 1, + attemptNumber: 1, + totalProvidersAttempted: 2, + isFirstAttempt: false, + isFailoverSuccess: bindingIntent === "create", + endpointId: 42, + endpointUrl: "https://api.test.invalid/v1", + upstreamStatusCode: 200, + bindingIntent, + bindingSnapshot: { + sessionId: `session-client-abort-${bindingIntent}`, + keyId: 2, + providerId, + generation: `${bindingIntent}-generation`, + }, + requiresCompletionMarkerForBinding: true, + discoveryLease: { + sessionId: `session-client-abort-${bindingIntent}`, + keyId: 2, + ownerToken: `client-abort-${bindingIntent}-owner`, + ttlSeconds: 30, + }, + providerSessionRefOwned: true, + }); - await ProxyResponseHandler.dispatch(session, createCompletedThenErroredResponsesSse()); - await drainAsyncTasks(); + await ProxyResponseHandler.dispatch(session, createCompletedThenErroredResponsesSse()); + await drainAsyncTasks(); - expect(SessionManager.clearVersionedSessionProvider).not.toHaveBeenCalled(); - expect(SessionManager.clearSessionProvider).not.toHaveBeenCalled(); - expect(SessionManager.compareAndSetSessionProvider).not.toHaveBeenCalled(); - expect(SessionManager.updateSessionBindingSmart).not.toHaveBeenCalled(); - expect(SessionManager.updateSessionWithCodexCacheKey).not.toHaveBeenCalled(); - expect(SessionManager.releaseSessionDiscoveryLease).toHaveBeenCalledOnce(); - expect(SessionManager.releaseSessionDiscoveryLease).toHaveBeenCalledWith( - `session-client-abort-${bindingIntent}`, - 2, - `client-abort-${bindingIntent}-owner` - ); - expect(RateLimitService.releaseProviderSession).toHaveBeenCalledWith( - 1, - `session-client-abort-${bindingIntent}` - ); - }); + expect(SessionManager.clearVersionedSessionProvider).not.toHaveBeenCalled(); + expect(SessionManager.clearSessionProvider).not.toHaveBeenCalled(); + expect(SessionManager.compareAndSetSessionProvider).not.toHaveBeenCalled(); + expect(SessionManager.updateSessionBindingSmart).not.toHaveBeenCalled(); + expect(SessionManager.updateSessionWithCodexCacheKey).not.toHaveBeenCalled(); + expect(SessionManager.releaseSessionDiscoveryLease).toHaveBeenCalledOnce(); + expect(SessionManager.releaseSessionDiscoveryLease).toHaveBeenCalledWith( + `session-client-abort-${bindingIntent}`, + 2, + `client-abort-${bindingIntent}-owner` + ); + expect(RateLimitService.releaseProviderSession).toHaveBeenCalledWith( + 1, + `session-client-abort-${bindingIntent}` + ); + } + ); it("keeps a genuinely aborted upstream responses stream as 499", async () => { const controller = new AbortController(); @@ -3037,52 +3039,55 @@ describe("ProxyResponseHandler stream client abort finalization", () => { it.each([ ["response timeout", "timeout"], ["client abort", "client"], - ] as const)("uses the conditional fallback when the non-stream %s finalizer durable write rejects", async (_name, abortSource) => { - vi.mocked(updateMessageRequestDetailsDurably).mockRejectedValueOnce( - new Error("durable finalizer acknowledgement failed") - ); - const clientController = new AbortController(); - const responseController = new AbortController(); - const session = createSession(clientController.signal); - Object.assign(session, { responseController }); - const response = createAbortableNonStreamResponse( - abortSource === "timeout" ? responseController.signal : clientController.signal - ); + ] as const)( + "uses the conditional fallback when the non-stream %s finalizer durable write rejects", + async (_name, abortSource) => { + vi.mocked(updateMessageRequestDetailsDurably).mockRejectedValueOnce( + new Error("durable finalizer acknowledgement failed") + ); + const clientController = new AbortController(); + const responseController = new AbortController(); + const session = createSession(clientController.signal); + Object.assign(session, { responseController }); + const response = createAbortableNonStreamResponse( + abortSource === "timeout" ? responseController.signal : clientController.signal + ); - await ProxyResponseHandler.dispatch(session, response); - const abortError = new Error(`non-stream ${abortSource}`); - abortError.name = "AbortError"; - if (abortSource === "timeout") { - responseController.abort(abortError); - } else { - clientController.abort(abortError); - } - await drainAsyncTasks(); + await ProxyResponseHandler.dispatch(session, response); + const abortError = new Error(`non-stream ${abortSource}`); + abortError.name = "AbortError"; + if (abortSource === "timeout") { + responseController.abort(abortError); + } else { + clientController.abort(abortError); + } + await drainAsyncTasks(); - expect(updateMessageRequestDetails).not.toHaveBeenCalled(); - expect(updateMessageRequestDetailsIfUnfinalized).toHaveBeenCalledTimes(1); - expect(updateMessageRequestDetailsIfUnfinalized).toHaveBeenCalledWith( - 123, - expect.objectContaining({ - statusCode: abortSource === "timeout" ? 502 : 499, - ...(abortSource === "timeout" - ? { errorMessage: expect.stringContaining("non-stream timeout") } - : {}), - providerId: 1, - providerChain: - abortSource === "timeout" - ? [ - expect.objectContaining({ - id: 1, - statusCode: 502, - errorMessage: expect.stringContaining("non-stream timeout"), - }), - ] - : [], - }), - expect.objectContaining({ onCommitted: expect.any(Function) }) - ); - }); + expect(updateMessageRequestDetails).not.toHaveBeenCalled(); + expect(updateMessageRequestDetailsIfUnfinalized).toHaveBeenCalledTimes(1); + expect(updateMessageRequestDetailsIfUnfinalized).toHaveBeenCalledWith( + 123, + expect.objectContaining({ + statusCode: abortSource === "timeout" ? 502 : 499, + ...(abortSource === "timeout" + ? { errorMessage: expect.stringContaining("non-stream timeout") } + : {}), + providerId: 1, + providerChain: + abortSource === "timeout" + ? [ + expect.objectContaining({ + id: 1, + statusCode: 502, + errorMessage: expect.stringContaining("non-stream timeout"), + }), + ] + : [], + }), + expect.objectContaining({ onCommitted: expect.any(Function) }) + ); + } + ); it("rejects non-stream processing when both terminal persistence attempts fail", async () => { vi.mocked(updateMessageRequestDetailsDurably).mockRejectedValueOnce( @@ -3164,25 +3169,28 @@ describe("ProxyResponseHandler stream client abort finalization", () => { model: "gemini-2.0-flash", }, ], - ] as const)("keeps non-stream 404 out of the Provider circuit for %s responses", async (_name, overrides) => { - const session = createSession(new AbortController().signal, overrides); - const response = new Response('{"error":{"message":"model not found"}}', { - status: 404, - headers: { "content-type": "application/json" }, - }); + ] as const)( + "keeps non-stream 404 out of the Provider circuit for %s responses", + async (_name, overrides) => { + const session = createSession(new AbortController().signal, overrides); + const response = new Response('{"error":{"message":"model not found"}}', { + status: 404, + headers: { "content-type": "application/json" }, + }); - await ProxyResponseHandler.dispatch(session, response); - await drainAsyncTasks(); + await ProxyResponseHandler.dispatch(session, response); + await drainAsyncTasks(); - expect(recordFailure).not.toHaveBeenCalled(); - expect(session.getProviderChain()).toEqual([ - expect.objectContaining({ - id: 1, - reason: "resource_not_found", - statusCode: 404, - }), - ]); - }); + expect(recordFailure).not.toHaveBeenCalled(); + expect(session.getProviderChain()).toEqual([ + expect.objectContaining({ + id: 1, + reason: "resource_not_found", + statusCode: 404, + }), + ]); + } + ); it("persists Gemini non-stream duration atomically with terminal stats", async () => { const session = createSession(new AbortController().signal, { diff --git a/tests/unit/proxy/response-handler-endpoint-circuit-isolation.test.ts b/tests/unit/proxy/response-handler-endpoint-circuit-isolation.test.ts index 8af778a50..169ecfdb9 100644 --- a/tests/unit/proxy/response-handler-endpoint-circuit-isolation.test.ts +++ b/tests/unit/proxy/response-handler-endpoint-circuit-isolation.test.ts @@ -1026,54 +1026,54 @@ describe("Endpoint circuit breaker isolation", () => { format: "gemini" as const, body: `${JSON.stringify({ candidates: [{ content: { parts: [{ text: "ok" }] } }] })}\n`, }, - ])("keeps a naturally completed $label stream successful but unbound without a marker", async ({ - format, - body, - }) => { - const session = createSession(); - session.originalFormat = format; - if (format === "gemini" || format === "gemini-cli") { - session.provider = { ...session.provider!, providerType: format }; - } - const snapshot = { - sessionId: "fake-session", - keyId: 456, - providerId: null, - generation: `${format}-natural-eof-generation`, - } as const; - setDeferredStreamingFinalization(session, { - providerId: 1, - providerName: "test-provider", - providerPriority: 10, - attemptNumber: 1, - totalProvidersAttempted: 2, - isFirstAttempt: false, - isFailoverSuccess: true, - endpointId: 42, - endpointUrl: "https://api.test.com", - upstreamStatusCode: 200, - bindingIntent: "create", - bindingSnapshot: snapshot, - requiresCompletionMarkerForBinding: true, - }); + ])( + "keeps a naturally completed $label stream successful but unbound without a marker", + async ({ format, body }) => { + const session = createSession(); + session.originalFormat = format; + if (format === "gemini" || format === "gemini-cli") { + session.provider = { ...session.provider!, providerType: format }; + } + const snapshot = { + sessionId: "fake-session", + keyId: 456, + providerId: null, + generation: `${format}-natural-eof-generation`, + } as const; + setDeferredStreamingFinalization(session, { + providerId: 1, + providerName: "test-provider", + providerPriority: 10, + attemptNumber: 1, + totalProvidersAttempted: 2, + isFirstAttempt: false, + isFailoverSuccess: true, + endpointId: 42, + endpointUrl: "https://api.test.com", + upstreamStatusCode: 200, + bindingIntent: "create", + bindingSnapshot: snapshot, + requiresCompletionMarkerForBinding: true, + }); - const clientResponse = await ProxyResponseHandler.dispatch( - session, - new Response(body, { - status: 200, - headers: { "content-type": "text/event-stream" }, - }) - ); - await expect(clientResponse.text()).resolves.toContain("ok"); - await drainAsyncTasks(); + const clientResponse = await ProxyResponseHandler.dispatch( + session, + new Response(body, { + status: 200, + headers: { "content-type": "text/event-stream" }, + }) + ); + await expect(clientResponse.text()).resolves.toContain("ok"); + await drainAsyncTasks(); - expect(SessionManager.compareAndSetSessionProvider).not.toHaveBeenCalled(); - expect(mockRecordFailure).not.toHaveBeenCalled(); - expect(mockRecordSuccess).toHaveBeenCalledWith(1); - const details = vi.mocked(updateMessageRequestDetailsDurably).mock.calls.at(-1)?.[1]; - expect(details).toEqual(expect.objectContaining({ statusCode: 200 })); - expect(details).not.toHaveProperty("errorMessage"); - }); + expect(SessionManager.compareAndSetSessionProvider).not.toHaveBeenCalled(); + expect(mockRecordFailure).not.toHaveBeenCalled(); + expect(mockRecordSuccess).toHaveBeenCalledWith(1); + const details = vi.mocked(updateMessageRequestDetailsDurably).mock.calls.at(-1)?.[1]; + expect(details).toEqual(expect.objectContaining({ statusCode: 200 })); + expect(details).not.toHaveProperty("errorMessage"); + } + ); it("does not accept completion marker words embedded in ordinary SSE content", async () => { const session = createSession(); diff --git a/tests/unit/proxy/session.test.ts b/tests/unit/proxy/session.test.ts index 306e5ba3e..5c77a0a29 100644 --- a/tests/unit/proxy/session.test.ts +++ b/tests/unit/proxy/session.test.ts @@ -117,19 +117,19 @@ function createSession({ } describe("ProxySession endpoint policy", () => { - it.each([ - V1_ENDPOINT_PATHS.MESSAGES_COUNT_TOKENS, - "/V1/RESPONSES/COMPACT/", - ])("应在创建时解析 raw passthrough policy: %s", (pathname) => { - const session = createSession({ - redirectedModel: null, - requestUrl: new URL(`http://localhost${pathname}`), - }); - - const policy = session.getEndpointPolicy(); - expect(isRawPassthroughEndpointPolicy(policy)).toBe(true); - expect(policy.trackConcurrentRequests).toBe(false); - }); + it.each([V1_ENDPOINT_PATHS.MESSAGES_COUNT_TOKENS, "/V1/RESPONSES/COMPACT/"])( + "应在创建时解析 raw passthrough policy: %s", + (pathname) => { + const session = createSession({ + redirectedModel: null, + requestUrl: new URL(`http://localhost${pathname}`), + }); + + const policy = session.getEndpointPolicy(); + expect(isRawPassthroughEndpointPolicy(policy)).toBe(true); + expect(policy.trackConcurrentRequests).toBe(false); + } + ); it("应在请求路径后续变更后保持创建时 policy 不变", () => { const session = createSession({ diff --git a/tests/unit/repository/message-terminal-public-status-seam.test.ts b/tests/unit/repository/message-terminal-public-status-seam.test.ts index 5600e335e..33748d342 100644 --- a/tests/unit/repository/message-terminal-public-status-seam.test.ts +++ b/tests/unit/repository/message-terminal-public-status-seam.test.ts @@ -52,246 +52,246 @@ describe("message terminal public-status public seam", () => { vi.doUnmock("@/lib/redis"); }); - it.each([ - "primary-first", - "fallback-first", - ])("%s publishes exactly one rollup from the terminal SQL owner", async (ownerOrder) => { - vi.resetModules(); - vi.useFakeTimers(); - - const id = ownerOrder === "primary-first" ? 91_001 : 91_002; - const row: TerminalRow = { - id, - createdAt: new Date("2026-07-13T12:00:00.000Z"), - model: "gpt-4.1", - originalModel: "gpt-4.1", - durationMs: null, - statusCode: null, - }; - const releasePrimary = createDeferred(); - const primaryReceipts: number[][] = []; - const fallbackReceipts: number[][] = []; - const primarySql: Array<{ sql: string; params: unknown[] }> = []; - const rollupPipelines: Array> = []; - - const primaryDetails = { - durationMs: 1_200, - statusCode: 200, - outputTokens: 60, - providerChain: [ - { - id: 1, - name: "primary-provider", - groupTag: "openai", - reason: "request_success" as const, - statusCode: 200, - }, - ], - model: "gpt-4.1", - }; - const fallbackDetails = { - durationMs: 2_400, - statusCode: 504, - outputTokens: 0, - errorMessage: "Error: stream_finalization_timeout", - providerChain: [ - { - id: 2, - name: "fallback-provider", - groupTag: "openai", - reason: "retry_failed" as const, - statusCode: 504, - }, - ], - model: "gpt-4.1", - }; - - const execute = vi.fn(async (query: Parameters[0]) => { - const built = toSqlText(query); - primarySql.push(built); - await releasePrimary.promise; - if (row.statusCode !== null) { - primaryReceipts.push([]); - return []; - } - row.durationMs = primaryDetails.durationMs; - row.statusCode = primaryDetails.statusCode; - primaryReceipts.push([id]); - return [{ id }]; - }); + it.each(["primary-first", "fallback-first"])( + "%s publishes exactly one rollup from the terminal SQL owner", + async (ownerOrder) => { + vi.resetModules(); + vi.useFakeTimers(); + + const id = ownerOrder === "primary-first" ? 91_001 : 91_002; + const row: TerminalRow = { + id, + createdAt: new Date("2026-07-13T12:00:00.000Z"), + model: "gpt-4.1", + originalModel: "gpt-4.1", + durationMs: null, + statusCode: null, + }; + const releasePrimary = createDeferred(); + const primaryReceipts: number[][] = []; + const fallbackReceipts: number[][] = []; + const primarySql: Array<{ sql: string; params: unknown[] }> = []; + const rollupPipelines: Array> = []; + + const primaryDetails = { + durationMs: 1_200, + statusCode: 200, + outputTokens: 60, + providerChain: [ + { + id: 1, + name: "primary-provider", + groupTag: "openai", + reason: "request_success" as const, + statusCode: 200, + }, + ], + model: "gpt-4.1", + }; + const fallbackDetails = { + durationMs: 2_400, + statusCode: 504, + outputTokens: 0, + errorMessage: "Error: stream_finalization_timeout", + providerChain: [ + { + id: 2, + name: "fallback-provider", + groupTag: "openai", + reason: "retry_failed" as const, + statusCode: 504, + }, + ], + model: "gpt-4.1", + }; - const writerUpdate = vi.fn(() => ({ - set: vi.fn((patch: Record) => ({ - where: vi.fn(() => ({ - returning: vi.fn(async () => { - if (row.statusCode !== null) { - fallbackReceipts.push([]); - return []; - } - row.durationMs = patch.durationMs as number; - row.statusCode = patch.statusCode as number; - fallbackReceipts.push([id]); - return [{ id }]; - }), + const execute = vi.fn(async (query: Parameters[0]) => { + const built = toSqlText(query); + primarySql.push(built); + await releasePrimary.promise; + if (row.statusCode !== null) { + primaryReceipts.push([]); + return []; + } + row.durationMs = primaryDetails.durationMs; + row.statusCode = primaryDetails.statusCode; + primaryReceipts.push([id]); + return [{ id }]; + }); + + const writerUpdate = vi.fn(() => ({ + set: vi.fn((patch: Record) => ({ + where: vi.fn(() => ({ + returning: vi.fn(async () => { + if (row.statusCode !== null) { + fallbackReceipts.push([]); + return []; + } + row.durationMs = patch.durationMs as number; + row.statusCode = patch.statusCode as number; + fallbackReceipts.push([id]); + return [{ id }]; + }), + })), })), - })), - })); - const writerDb = { execute, update: writerUpdate }; - - vi.doMock("@/drizzle/db", () => ({ - db: { - select: vi.fn(() => ({ - from: vi.fn(() => ({ - where: vi.fn(() => ({ - limit: vi.fn(async () => [ - { - createdAt: row.createdAt, - model: row.model, - originalModel: row.originalModel, - durationMs: row.durationMs, - }, - ]), + })); + const writerDb = { execute, update: writerUpdate }; + + vi.doMock("@/drizzle/db", () => ({ + db: { + select: vi.fn(() => ({ + from: vi.fn(() => ({ + where: vi.fn(() => ({ + limit: vi.fn(async () => [ + { + createdAt: row.createdAt, + model: row.model, + originalModel: row.originalModel, + durationMs: row.durationMs, + }, + ]), + })), })), })), - })), - update: vi.fn(), - }, - getMessageWriterDb: vi.fn(() => writerDb), - })); - vi.doMock("@/lib/config/env.schema", () => ({ - getEnvConfig: () => ({ - MESSAGE_REQUEST_WRITE_MODE: "async", - MESSAGE_REQUEST_ASYNC_FLUSH_INTERVAL_MS: 60_000, - MESSAGE_REQUEST_ASYNC_BATCH_SIZE: 1_000, - MESSAGE_REQUEST_ASYNC_MAX_PENDING: 1_000, - }), - })); - vi.doMock("@/lib/logger", () => ({ - logger: { - trace: vi.fn(), - debug: vi.fn(), - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - }, - })); - - const configSnapshot = JSON.stringify({ - configVersion: "cfg-r2-seam", - generatedAt: "2026-07-13T11:59:00.000Z", - siteTitle: "Status", - siteDescription: "Status", - timeZone: "UTC", - defaultIntervalMinutes: 5, - defaultRangeHours: 24, - groups: [ - { - sourceGroupId: 42, - sourceGroupName: "openai", - slug: "openai", - displayName: "OpenAI", - sortOrder: 1, - description: null, - models: [ - { - publicModelKey: "gpt-4.1", - label: "GPT-4.1", - vendorIconKey: "openai", - requestTypeBadge: "openaiCompatible", - }, - ], + update: vi.fn(), }, - ], - }); - const redis = { - status: "ready", - hincrbyfloat: vi.fn(), - get: vi.fn(async (key: string) => { - if (key === "public-status:v2:config-version:current") { - return "cfg-r2-seam"; - } - if (key === "public-status:v2:config-internal:cfg-r2-seam") { - return configSnapshot; - } - return null; - }), - pipeline: vi.fn(() => { - const operations: Array<{ command: string; args: unknown[] }> = []; - return { - hincrbyfloat: (...args: unknown[]) => { - operations.push({ command: "hincrbyfloat", args }); - }, - set: (...args: unknown[]) => { - operations.push({ command: "set", args }); - }, - expire: (...args: unknown[]) => { - operations.push({ command: "expire", args }); - }, - exec: async () => { - rollupPipelines.push(operations); - return operations.map(() => [null, 1] as [null, number]); + getMessageWriterDb: vi.fn(() => writerDb), + })); + vi.doMock("@/lib/config/env.schema", () => ({ + getEnvConfig: () => ({ + MESSAGE_REQUEST_WRITE_MODE: "async", + MESSAGE_REQUEST_ASYNC_FLUSH_INTERVAL_MS: 60_000, + MESSAGE_REQUEST_ASYNC_BATCH_SIZE: 1_000, + MESSAGE_REQUEST_ASYNC_MAX_PENDING: 1_000, + }), + })); + vi.doMock("@/lib/logger", () => ({ + logger: { + trace: vi.fn(), + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, + })); + + const configSnapshot = JSON.stringify({ + configVersion: "cfg-r2-seam", + generatedAt: "2026-07-13T11:59:00.000Z", + siteTitle: "Status", + siteDescription: "Status", + timeZone: "UTC", + defaultIntervalMinutes: 5, + defaultRangeHours: 24, + groups: [ + { + sourceGroupId: 42, + sourceGroupName: "openai", + slug: "openai", + displayName: "OpenAI", + sortOrder: 1, + description: null, + models: [ + { + publicModelKey: "gpt-4.1", + label: "GPT-4.1", + vendorIconKey: "openai", + requestTypeBadge: "openaiCompatible", + }, + ], }, - }; - }), - }; - vi.doMock("@/lib/redis", () => ({ - getRedisClient: vi.fn(() => redis), - })); - - const { updateMessageRequestDetailsDurably, updateMessageRequestDetailsIfUnfinalized } = - await import("@/repository/message"); - const { flushMessageRequestWriteBuffer, stopMessageRequestWriteBuffer } = await import( - "@/repository/message-write-buffer" - ); - - const primary = updateMessageRequestDetailsDurably(id, primaryDetails, { timeoutMs: 10 }); - const primaryResult = primary.catch((error: unknown) => error); - const flush = flushMessageRequestWriteBuffer(); - - await vi.advanceTimersByTimeAsync(10); - await expect(primaryResult).resolves.toEqual( - expect.objectContaining({ - message: "durable message_request acknowledgement timed out", - }) - ); - - if (ownerOrder === "fallback-first") { - await updateMessageRequestDetailsIfUnfinalized(id, fallbackDetails); - releasePrimary.resolve(); - await flush; - } else { - releasePrimary.resolve(); - await flush; - await updateMessageRequestDetailsIfUnfinalized(id, fallbackDetails); + ], + }); + const redis = { + status: "ready", + hincrbyfloat: vi.fn(), + get: vi.fn(async (key: string) => { + if (key === "public-status:v2:config-version:current") { + return "cfg-r2-seam"; + } + if (key === "public-status:v2:config-internal:cfg-r2-seam") { + return configSnapshot; + } + return null; + }), + pipeline: vi.fn(() => { + const operations: Array<{ command: string; args: unknown[] }> = []; + return { + hincrbyfloat: (...args: unknown[]) => { + operations.push({ command: "hincrbyfloat", args }); + }, + set: (...args: unknown[]) => { + operations.push({ command: "set", args }); + }, + expire: (...args: unknown[]) => { + operations.push({ command: "expire", args }); + }, + exec: async () => { + rollupPipelines.push(operations); + return operations.map(() => [null, 1] as [null, number]); + }, + }; + }), + }; + vi.doMock("@/lib/redis", () => ({ + getRedisClient: vi.fn(() => redis), + })); + + const { updateMessageRequestDetailsDurably, updateMessageRequestDetailsIfUnfinalized } = + await import("@/repository/message"); + const { flushMessageRequestWriteBuffer, stopMessageRequestWriteBuffer } = await import( + "@/repository/message-write-buffer" + ); + + const primary = updateMessageRequestDetailsDurably(id, primaryDetails, { timeoutMs: 10 }); + const primaryResult = primary.catch((error: unknown) => error); + const flush = flushMessageRequestWriteBuffer(); + + await vi.advanceTimersByTimeAsync(10); + await expect(primaryResult).resolves.toEqual( + expect.objectContaining({ + message: "durable message_request acknowledgement timed out", + }) + ); + + if (ownerOrder === "fallback-first") { + await updateMessageRequestDetailsIfUnfinalized(id, fallbackDetails); + releasePrimary.resolve(); + await flush; + } else { + releasePrimary.resolve(); + await flush; + await updateMessageRequestDetailsIfUnfinalized(id, fallbackDetails); + } + await flushMicrotasks(); + + expect(primarySql).toHaveLength(1); + expect(primarySql[0]?.sql).toMatch(/"?status_code"? IS NULL/); + expect(primarySql[0]?.sql).toContain("RETURNING id"); + expect(primaryReceipts).toEqual(ownerOrder === "primary-first" ? [[id]] : [[]]); + expect(fallbackReceipts).toEqual(ownerOrder === "fallback-first" ? [[id]] : [[]]); + expect(row).toMatchObject( + ownerOrder === "primary-first" + ? { durationMs: primaryDetails.durationMs, statusCode: primaryDetails.statusCode } + : { durationMs: fallbackDetails.durationMs, statusCode: fallbackDetails.statusCode } + ); + expect(redis.get.mock.calls).toEqual([ + ["public-status:v2:config-version:current"], + ["public-status:v2:config-internal:cfg-r2-seam"], + ]); + expect(rollupPipelines).toHaveLength(1); + + const rollupFields = rollupPipelines[0]! + .filter((operation) => operation.command === "hincrbyfloat") + .map((operation) => String(operation.args[1])); + const expectedMetric = ownerOrder === "primary-first" ? "success" : "failure"; + const losingMetric = ownerOrder === "primary-first" ? "failure" : "success"; + expect(rollupFields).toContain(`42|gpt-4.1|${expectedMetric}`); + expect(rollupFields).not.toContain(`42|gpt-4.1|${losingMetric}`); + + await stopMessageRequestWriteBuffer(); } - await flushMicrotasks(); - - expect(primarySql).toHaveLength(1); - expect(primarySql[0]?.sql).toMatch(/"?status_code"? IS NULL/); - expect(primarySql[0]?.sql).toContain("RETURNING id"); - expect(primaryReceipts).toEqual(ownerOrder === "primary-first" ? [[id]] : [[]]); - expect(fallbackReceipts).toEqual(ownerOrder === "fallback-first" ? [[id]] : [[]]); - expect(row).toMatchObject( - ownerOrder === "primary-first" - ? { durationMs: primaryDetails.durationMs, statusCode: primaryDetails.statusCode } - : { durationMs: fallbackDetails.durationMs, statusCode: fallbackDetails.statusCode } - ); - expect(redis.get.mock.calls).toEqual([ - ["public-status:v2:config-version:current"], - ["public-status:v2:config-internal:cfg-r2-seam"], - ]); - expect(rollupPipelines).toHaveLength(1); - - const rollupFields = rollupPipelines[0]! - .filter((operation) => operation.command === "hincrbyfloat") - .map((operation) => String(operation.args[1])); - const expectedMetric = ownerOrder === "primary-first" ? "success" : "failure"; - const losingMetric = ownerOrder === "primary-first" ? "failure" : "success"; - expect(rollupFields).toContain(`42|gpt-4.1|${expectedMetric}`); - expect(rollupFields).not.toContain(`42|gpt-4.1|${losingMetric}`); - - await stopMessageRequestWriteBuffer(); - }); + ); it("same-ID pending durable contention publishes one rollup from the first owner", async () => { vi.resetModules(); diff --git a/tests/unit/repository/message-write-buffer.test.ts b/tests/unit/repository/message-write-buffer.test.ts index d726d63d7..a3c0fefb8 100644 --- a/tests/unit/repository/message-write-buffer.test.ts +++ b/tests/unit/repository/message-write-buffer.test.ts @@ -1070,79 +1070,80 @@ describe("message_request 异步批量写入", () => { it.each([ { databaseOutcome: "成功", shouldReject: false }, { databaseOutcome: "失败", shouldReject: true }, - ])("executor 首次同步重入 stop 时应共享同一 Promise, 并等待 DB $databaseOutcome", async ({ - shouldReject, - }) => { - process.env.MESSAGE_REQUEST_WRITE_MODE = "async"; - - const databaseBarrier = createDeferred>(); - const databaseError = new Error("db unavailable"); - let reentrantStopPromise: Promise | undefined; - let stopMessageRequestWriteBuffer!: () => Promise; - - executeMock.mockImplementation((query) => { - if (!reentrantStopPromise) { - reentrantStopPromise = stopMessageRequestWriteBuffer(); - return databaseBarrier.promise; + ])( + "executor 首次同步重入 stop 时应共享同一 Promise, 并等待 DB $databaseOutcome", + async ({ shouldReject }) => { + process.env.MESSAGE_REQUEST_WRITE_MODE = "async"; + + const databaseBarrier = createDeferred>(); + const databaseError = new Error("db unavailable"); + let reentrantStopPromise: Promise | undefined; + let stopMessageRequestWriteBuffer!: () => Promise; + + executeMock.mockImplementation((query) => { + if (!reentrantStopPromise) { + reentrantStopPromise = stopMessageRequestWriteBuffer(); + return databaseBarrier.promise; + } + return shouldReject + ? Promise.reject(databaseError) + : Promise.resolve(successfulRowsForQuery(query)); + }); + + const messageWriteBuffer = await import("@/repository/message-write-buffer"); + stopMessageRequestWriteBuffer = messageWriteBuffer.stopMessageRequestWriteBuffer; + messageWriteBuffer.enqueueMessageRequestUpdate(42, { durationMs: 100 }); + + const outerStopPromise = stopMessageRequestWriteBuffer(); + const reentrantPromise = reentrantStopPromise; + if (!reentrantPromise) { + throw new Error("executor did not synchronously re-enter stop"); } - return shouldReject - ? Promise.reject(databaseError) - : Promise.resolve(successfulRowsForQuery(query)); - }); - - const messageWriteBuffer = await import("@/repository/message-write-buffer"); - stopMessageRequestWriteBuffer = messageWriteBuffer.stopMessageRequestWriteBuffer; - messageWriteBuffer.enqueueMessageRequestUpdate(42, { durationMs: 100 }); + const samePromise = outerStopPromise === reentrantPromise; + let outerSettled = false; + let reentrantSettled = false; + void outerStopPromise.then( + () => { + outerSettled = true; + }, + () => { + outerSettled = true; + } + ); + void reentrantPromise.then( + () => { + reentrantSettled = true; + }, + () => { + reentrantSettled = true; + } + ); + await new Promise((resolve) => setImmediate(resolve)); + const settlementsBeforeRelease = [outerSettled, reentrantSettled]; - const outerStopPromise = stopMessageRequestWriteBuffer(); - const reentrantPromise = reentrantStopPromise; - if (!reentrantPromise) { - throw new Error("executor did not synchronously re-enter stop"); - } - const samePromise = outerStopPromise === reentrantPromise; - let outerSettled = false; - let reentrantSettled = false; - void outerStopPromise.then( - () => { - outerSettled = true; - }, - () => { - outerSettled = true; + if (shouldReject) { + databaseBarrier.reject(databaseError); + } else { + databaseBarrier.resolve([]); } - ); - void reentrantPromise.then( - () => { - reentrantSettled = true; - }, - () => { - reentrantSettled = true; + const stopResults = await Promise.allSettled([outerStopPromise, reentrantPromise]); + + expect(settlementsBeforeRelease).toEqual([false, false]); + if (shouldReject) { + const shutdownError = "message_request writer shutdown persistence failed"; + expect(stopResults).toEqual([ + { status: "rejected", reason: expect.objectContaining({ message: shutdownError }) }, + { status: "rejected", reason: expect.objectContaining({ message: shutdownError }) }, + ]); + } else { + expect(stopResults).toEqual([ + { status: "fulfilled", value: undefined }, + { status: "fulfilled", value: undefined }, + ]); } - ); - await new Promise((resolve) => setImmediate(resolve)); - const settlementsBeforeRelease = [outerSettled, reentrantSettled]; - - if (shouldReject) { - databaseBarrier.reject(databaseError); - } else { - databaseBarrier.resolve([]); + expect(samePromise).toBe(true); } - const stopResults = await Promise.allSettled([outerStopPromise, reentrantPromise]); - - expect(settlementsBeforeRelease).toEqual([false, false]); - if (shouldReject) { - const shutdownError = "message_request writer shutdown persistence failed"; - expect(stopResults).toEqual([ - { status: "rejected", reason: expect.objectContaining({ message: shutdownError }) }, - { status: "rejected", reason: expect.objectContaining({ message: shutdownError }) }, - ]); - } else { - expect(stopResults).toEqual([ - { status: "fulfilled", value: undefined }, - { status: "fulfilled", value: undefined }, - ]); - } - expect(samePromise).toBe(true); - }); + ); it("stop 无法刷写剩余终态时所有调用都应持续拒绝同一错误", async () => { process.env.MESSAGE_REQUEST_WRITE_MODE = "async"; diff --git a/tests/unit/server-response-write-backpressure.test.ts b/tests/unit/server-response-write-backpressure.test.ts index ce257940e..f8c02a456 100644 --- a/tests/unit/server-response-write-backpressure.test.ts +++ b/tests/unit/server-response-write-backpressure.test.ts @@ -137,50 +137,50 @@ describe("server response write backpressure", () => { await forwarding; }); - it.each([ - "ECONNREFUSED", - "ECONNRESET", - ])("sends one fatal frame and waits for its acknowledgement on active request error %s", async (code) => { - const events: string[] = []; - const request = createClientRequest(false, events); - vi.spyOn(http, "request").mockImplementation(() => request); - const input = requestInput(); - const sent: string[] = []; - let sendCallback: ((error?: Error) => void) | undefined; - input.ws.send = (payload, callback) => { - sent.push(payload); - sendCallback = callback; - }; - const close = vi.fn(); - - const forwarding = serverModule.forwardToInternalHttp( - input.ws, - input.request, - input.body, - "request-error-session", - undefined, - close - ); - let settled = false; - void forwarding.then(() => { - settled = true; - }); - - request.emit("error", Object.assign(new Error(code), { code })); - await new Promise((resolve) => setImmediate(resolve)); - - expect(sent).toHaveLength(1); - expect(JSON.parse(sent[0]).error.code).toBe("internal_request_error"); - expect(settled).toBe(false); - expect(close).not.toHaveBeenCalled(); - - sendCallback?.(); - await forwarding; - expect(close).toHaveBeenCalledWith(1011, "internal_request_error"); - - expect(() => request.emit("error", new Error("late request error"))).not.toThrow(); - expect(sent).toHaveLength(1); - }); + it.each(["ECONNREFUSED", "ECONNRESET"])( + "sends one fatal frame and waits for its acknowledgement on active request error %s", + async (code) => { + const events: string[] = []; + const request = createClientRequest(false, events); + vi.spyOn(http, "request").mockImplementation(() => request); + const input = requestInput(); + const sent: string[] = []; + let sendCallback: ((error?: Error) => void) | undefined; + input.ws.send = (payload, callback) => { + sent.push(payload); + sendCallback = callback; + }; + const close = vi.fn(); + + const forwarding = serverModule.forwardToInternalHttp( + input.ws, + input.request, + input.body, + "request-error-session", + undefined, + close + ); + let settled = false; + void forwarding.then(() => { + settled = true; + }); + + request.emit("error", Object.assign(new Error(code), { code })); + await new Promise((resolve) => setImmediate(resolve)); + + expect(sent).toHaveLength(1); + expect(JSON.parse(sent[0]).error.code).toBe("internal_request_error"); + expect(settled).toBe(false); + expect(close).not.toHaveBeenCalled(); + + sendCallback?.(); + await forwarding; + expect(close).toHaveBeenCalledWith(1011, "internal_request_error"); + + expect(() => request.emit("error", new Error("late request error"))).not.toThrow(); + expect(sent).toHaveLength(1); + } + ); it("force-settles an active turn without relying on request destroy events", async () => { const events: string[] = []; From 1871d59533bec518f623e682daf532367745dce4 Mon Sep 17 00:00:00 2001 From: ding113 Date: Thu, 30 Jul 2026 20:05:36 +0800 Subject: [PATCH 4/4] fix(proxy): record TFFT at Responses content gate commit Record TFFT when valid Responses content commits through the stream gate across sequential, legacy hedge, and Discovery transport paths. Previously TFFT was not captured at the gate-commit boundary, conflating first-byte and first-token timing. commitWinner now receives a contentGateCommitted flag so TFFT is recorded only when the content gate has committed the winner, not when a raw first chunk is forwarded. Winner TTFB remains distinct and is preserved at its original recording point. Integration tests verify TFFT and TTFB stay separate before any downstream read across sequential and first-byte hedge paths, and under Discovery winner selection. --- src/app/v1/_lib/proxy/forwarder.ts | 15 +- .../integration/proxy-hedge-lifecycle.test.ts | 281 +++++++++++++++++- 2 files changed, 285 insertions(+), 11 deletions(-) diff --git a/src/app/v1/_lib/proxy/forwarder.ts b/src/app/v1/_lib/proxy/forwarder.ts index 075fad513..7c4bc351a 100644 --- a/src/app/v1/_lib/proxy/forwarder.ts +++ b/src/app/v1/_lib/proxy/forwarder.ts @@ -1765,6 +1765,7 @@ export class ProxyForwarder { if (gateFirstByteAt !== null) { session.recordFirstByte(gateFirstByteAt); } + session.recordTfft(); if (gate.commitMarker) { gateChainAudit = { @@ -4761,7 +4762,7 @@ export class ProxyForwarder { } // 保留完整门控前缀:若本 attempt 落败且需要计费,drain 时补回前缀里的 usage。 attempt.firstChunk = concatChunks(gate.prefixChunks); - await commitWinner(attempt, gate.prefixChunks); + await commitWinner(attempt, gate.prefixChunks, true); } else { const firstChunk = await ProxyForwarder.readFirstReadableChunk(attempt.reader); if (firstChunk.done) { @@ -4774,7 +4775,7 @@ export class ProxyForwarder { // 保留首块:若本 attempt 落败且需要计费,drain 时需要补回首块的 usage。 attempt.firstChunk = firstChunk.value; - await commitWinner(attempt, [firstChunk.value]); + await commitWinner(attempt, [firstChunk.value], false); } // 本 attempt 读到首块却落败(winner 已先提交,commitWinner 早退): @@ -5044,7 +5045,11 @@ export class ProxyForwarder { await finishIfExhausted(); }; - const commitWinner = async (attempt: StreamingHedgeAttempt, prefixChunks: Uint8Array[]) => { + const commitWinner = async ( + attempt: StreamingHedgeAttempt, + prefixChunks: Uint8Array[], + contentGateCommitted: boolean + ) => { if (settled || winnerCommitted || attempt.settled || !attempt.response || !attempt.reader) return; @@ -5054,6 +5059,9 @@ export class ProxyForwarder { if (attempt.firstByteAt != null) { session.recordFirstByte(attempt.firstByteAt); } + if (contentGateCommitted) { + session.recordTfft(); + } if (attempt.thresholdTimer) { clearTimeout(attempt.thresholdTimer); @@ -6084,6 +6092,7 @@ export class ProxyForwarder { if (attempt.firstByteAt != null) { session.recordFirstByte(attempt.firstByteAt); } + session.recordTfft(); if (attempt.session !== session) ProxyForwarder.syncWinningAttemptSession(session, attempt.session); diff --git a/tests/integration/proxy-hedge-lifecycle.test.ts b/tests/integration/proxy-hedge-lifecycle.test.ts index 502d8490a..50b2d2700 100644 --- a/tests/integration/proxy-hedge-lifecycle.test.ts +++ b/tests/integration/proxy-hedge-lifecycle.test.ts @@ -3,9 +3,11 @@ import { createServer, type ServerResponse } from "node:http"; import type { Socket } from "node:net"; import { Context } from "hono"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { DiscoveryValidityParser } from "@/app/v1/_lib/proxy/discovery-validity"; import { ProxyForwarder } from "@/app/v1/_lib/proxy/forwarder"; import { ProxyResponseHandler } from "@/app/v1/_lib/proxy/response-handler"; import { type MessageContext, ProxySession } from "@/app/v1/_lib/proxy/session"; +import { SseFrameParser } from "@/app/v1/_lib/proxy/stream-gate/sse-frames"; import { DbPoolAdmissionError } from "@/drizzle/admitted-client"; import { getGlobalAgentPool, resetGlobalAgentPool } from "@/lib/proxy-agent"; import type { SessionBindingSnapshot } from "@/lib/redis/session-binding"; @@ -59,6 +61,7 @@ const state = vi.hoisted(() => { providers: Array.from([]), recordFailure: vi.fn(async () => {}), settleLeaseBudgets: vi.fn(async () => {}), + streamGateMode: "off", tasks: Array.from>([]), trackCost: vi.fn(async () => {}), updateMessageRequestCostWithBreakdown: vi.fn(async () => {}), @@ -99,6 +102,18 @@ vi.mock("@/lib/config", async (importOriginal) => { vi.mock("@/lib/config/system-settings-cache", () => ({ getCachedSystemSettings: async () => ({ billNonSuccessfulRequests: false }), })); +vi.mock("@/lib/system-settings/proxy-runtime", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getCachedProxyRuntimeSettings: () => ({ + affinityIgnoreClientSessionId: true, + cacheEffectivenessEnabled: true, + replayEnabled: false, + streamGateMode: state.streamGateMode, + }), + }; +}); vi.mock("@/app/v1/_lib/proxy/provider-selector", () => ({ ProxyProviderResolver: { pickDiscoveryProviders: state.pickDiscovery, @@ -367,6 +382,7 @@ type Upstream = { readonly response: Promise; readonly send: (body: string) => Promise; readonly terminated: Promise; + readonly write: (body: string) => Promise; }; async function startUpstream(): Promise { @@ -416,6 +432,18 @@ async function startUpstream(): Promise { await new Promise((resolve) => response.end(body, resolve)); }, terminated: terminationGate.promise, + write: async (body) => { + const response = await responseGate.promise; + await new Promise((resolve, reject) => { + response.write(body, (error) => { + if (error) { + reject(error); + return; + } + resolve(); + }); + }); + }, }; } @@ -424,13 +452,23 @@ async function createSession( pathname: string = "/v1/messages", signal?: AbortSignal ): Promise { + const isResponsesRequest = pathname === "/v1/responses"; const request = new Request(`https://hub.test${pathname}`, { - body: JSON.stringify({ - max_tokens: 32, - messages: [{ content: "integration", role: "user" }], - model: "claude-test", - stream: true, - }), + body: JSON.stringify( + isResponsesRequest + ? { + input: [{ content: "integration", role: "user" }], + model: "gpt-5.6-sol", + store: false, + stream: true, + } + : { + max_tokens: 32, + messages: [{ content: "integration", role: "user" }], + model: "claude-test", + stream: true, + } + ), headers: { "content-type": "application/json" }, method: "POST", ...(signal ? { signal } : {}), @@ -438,8 +476,8 @@ async function createSession( const session = await ProxySession.fromContext(new Context(request)); session.setAuthState({ apiKey: KEY.key, key: KEY, success: true, user: USER }); session.setMessageContext(MESSAGE); - session.setOriginalFormat("claude"); - session.setOriginalModel("claude-test"); + session.setOriginalFormat(isResponsesRequest ? "response" : "claude"); + session.setOriginalModel(isResponsesRequest ? "gpt-5.6-sol" : "claude-test"); session.setProvider(provider); return session; } @@ -450,6 +488,94 @@ function sse(inputTokens: number, outputTokens: number): string { })}\n\nevent: message_stop\ndata: {"type":"message_stop"}\n\n`; } +function responsesFrame(eventName: string, data: Record): string { + return `event: ${eventName}\ndata: ${JSON.stringify(data)}\n\n`; +} + +function responsesStreamFixture(responseId: string, itemId: string) { + return { + neutralPrefix: [ + responsesFrame("response.created", { + response: { id: responseId, status: "in_progress" }, + sequence_number: 0, + type: "response.created", + }), + responsesFrame("response.in_progress", { + response: { id: responseId, status: "in_progress" }, + sequence_number: 1, + type: "response.in_progress", + }), + responsesFrame("response.output_item.added", { + item: { + content: [], + id: itemId, + role: "assistant", + status: "in_progress", + type: "message", + }, + output_index: 0, + sequence_number: 2, + type: "response.output_item.added", + }), + responsesFrame("response.content_part.added", { + content_index: 0, + item_id: itemId, + output_index: 0, + part: { annotations: [], logprobs: [], text: "", type: "output_text" }, + sequence_number: 3, + type: "response.content_part.added", + }), + ], + firstContent: responsesFrame("response.output_text.delta", { + content_index: 0, + delta: "我", + item_id: itemId, + logprobs: [], + output_index: 0, + sequence_number: 5, + type: "response.output_text.delta", + }), + completed: responsesFrame("response.completed", { + response: { + id: responseId, + status: "completed", + usage: { input_tokens: 1, output_tokens: 1 }, + }, + sequence_number: 6, + type: "response.completed", + }), + } as const; +} + +function watchNeutralResponsesPrefixConsumption() { + const consumed = Promise.withResolvers(); + const decoder = new TextDecoder(); + let observed = ""; + const observe = (chunk: Uint8Array | string) => { + observed += typeof chunk === "string" ? chunk : decoder.decode(chunk, { stream: true }); + if (observed.includes('"sequence_number":3')) consumed.resolve(); + }; + const originalGatePush = SseFrameParser.prototype.push; + const gateSpy = vi.spyOn(SseFrameParser.prototype, "push").mockImplementation(function (chunk) { + observe(chunk); + return originalGatePush.call(this, chunk); + }); + const originalDiscoveryPush = DiscoveryValidityParser.prototype.push; + const discoverySpy = vi + .spyOn(DiscoveryValidityParser.prototype, "push") + .mockImplementation(function (chunk) { + observe(chunk); + return originalDiscoveryPush.call(this, chunk); + }); + return { + consumed: consumed.promise, + restore: () => { + gateSpy.mockRestore(); + discoverySpy.mockRestore(); + }, + } as const; +} + async function settleTasks(): Promise { while (state.tasks.length > 0) { const settlements = await Promise.allSettled(state.tasks.splice(0, state.tasks.length)); @@ -481,6 +607,7 @@ beforeEach(async () => { state.http2Error = null; state.loserBilled = Promise.withResolvers(); state.providers.length = 0; + state.streamGateMode = "off"; state.tasks.length = 0; state.addLoserCost.mockImplementation(async () => state.loserBilled.resolve()); state.pickAlternative.mockImplementation(async (_session: unknown, excludedIds: number[]) => { @@ -499,6 +626,144 @@ afterEach(async () => { }); describe("proxy hedge transport/lifecycle integration (persistence and control-plane seams mocked)", () => { + it.each([ + { + expectedMode: "legacy_serial", + firstByteTimeoutStreamingMs: 0, + pathName: "sequential", + }, + { + expectedMode: "legacy_hedge", + firstByteTimeoutStreamingMs: 5_000, + pathName: "first-byte hedge", + }, + ])( + "records TFFT at the enforced Responses gate commit before downstream reads ($pathName path)", + async ({ expectedMode, firstByteTimeoutStreamingMs }) => { + const upstream = await startUpstream(); + const client = new AbortController(); + const now = vi.spyOn(Date, "now"); + const neutralPrefixConsumption = watchNeutralResponsesPrefixConsumption(); + try { + // Given: the real fixture's four neutral events precede its first text delta. + now.mockReturnValue(10_000); + state.streamGateMode = "enforce"; + const provider = createProvider(1, upstream.baseUrl, firstByteTimeoutStreamingMs); + provider.providerType = "codex"; + const session = await createSession(provider, "/v1/responses", client.signal); + const agents = watchAgentReleases(1); + const stream = responsesStreamFixture("resp_gate", "msg_gate"); + + const forwarded = ProxyForwarder.send(session); + await upstream.response; + now.mockReturnValue(10_050); + await upstream.write(stream.neutralPrefix.join("")); + await neutralPrefixConsumption.consumed; + expect(session.firstByteMs).toBeNull(); + expect(session.tfftMs).toBeNull(); + + // When: sequence 5 arrives, it is the first user-visible content boundary. + now.mockReturnValue(10_125); + await upstream.write(stream.firstContent); + const forwardedResponse = await forwarded; + + // Then: TTFB and TFFT remain distinct before any downstream read occurs. + expect(session.firstByteMs).toBe(50); + expect(session.tfftMs).toBe(125); + expect(session.getRoutingTrace()?.mode).toBe(expectedMode); + const firstByteMsAtCommit = session.firstByteMs; + + now.mockReturnValue(10_900); + const downstream = await ProxyResponseHandler.dispatch(session, forwardedResponse); + await upstream.send(stream.completed); + await expect(downstream.text()).resolves.toBe( + [...stream.neutralPrefix, stream.firstContent, stream.completed].join("") + ); + await settleTasks(); + await agents.released; + + expect(session.firstByteMs).toBe(firstByteMsAtCommit); + expect(session.tfftMs).toBe(125); + expect(session.getProviderChain()).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + streamGate: expect.objectContaining({ + eventName: "response.output_text.delta", + frameIndex: 5, + }), + }), + ]) + ); + expect(agents.pool.getPoolStats().activeRequests).toBe(0); + } finally { + client.abort(new Error("fixture cleanup")); + neutralPrefixConsumption.restore(); + now.mockRestore(); + await upstream.close(); + } + } + ); + + it("records TFFT when a Discovery Responses winner commits before downstream reads", async () => { + const [loser, winner] = await Promise.all([startUpstream(), startUpstream()]); + const client = new AbortController(); + const now = vi.spyOn(Date, "now"); + const neutralPrefixConsumption = watchNeutralResponsesPrefixConsumption(); + try { + // Given: Discovery has two Codex attempts and only the alternative emits the real fixture. + now.mockReturnValue(10_000); + state.discoveryEnabled = true; + const initialProvider = createProvider(1, loser.baseUrl, 0); + initialProvider.providerType = "codex"; + const winningProvider = createProvider(2, winner.baseUrl, 0); + winningProvider.priority = initialProvider.priority; + winningProvider.providerType = "codex"; + state.providers.push(winningProvider); + const session = await createSession(initialProvider, "/v1/responses", client.signal); + session.sessionId = "integration-discovery-tfft"; + const agents = watchAgentReleases(2); + const stream = responsesStreamFixture("resp_discovery", "msg_discovery"); + + const forwarded = ProxyForwarder.send(session); + await Promise.all([loser.response, winner.response]); + now.mockReturnValue(10_050); + await winner.write(stream.neutralPrefix.join("")); + await neutralPrefixConsumption.consumed; + expect(session.tfftMs).toBeNull(); + + // When: sequence 5 makes the alternative ready and Discovery commits it. + now.mockReturnValue(10_125); + await winner.write(stream.firstContent); + const forwardedResponse = await forwarded; + + // Then: TFFT is fixed at winner commit, before ResponseHandler reads the stream. + expect(session.firstByteMs).toBe(50); + expect(session.tfftMs).toBe(125); + const firstByteMsAtCommit = session.firstByteMs; + + now.mockReturnValue(10_900); + const downstream = await ProxyResponseHandler.dispatch(session, forwardedResponse); + await winner.send(stream.completed); + await expect(downstream.text()).resolves.toBe( + [...stream.neutralPrefix, stream.firstContent, stream.completed].join("") + ); + await settleTasks(); + await loser.terminated; + await agents.released; + + expect(session.firstByteMs).toBe(firstByteMsAtCommit); + expect(session.tfftMs).toBe(125); + expect(loser.abortCount()).toBe(1); + expect(winner.abortCount()).toBe(0); + expect(agents.pool.getPoolStats().activeRequests).toBe(0); + } finally { + client.abort(new Error("fixture cleanup")); + neutralPrefixConsumption.restore(); + now.mockRestore(); + await Promise.all([loser.close(), winner.close()]); + } + }); + it("runs a leased Discovery race over real loopback transports and cancels the loser", async () => { const [loser, winner] = await Promise.all([startUpstream(), startUpstream()]); const client = new AbortController();