diff --git a/src/actions/providers.ts b/src/actions/providers.ts index a18e93980..a859ae03f 100644 --- a/src/actions/providers.ts +++ b/src/actions/providers.ts @@ -1593,6 +1593,12 @@ const GEMINI_ONLY_FIELDS: ReadonlySet = new Set([ "gemini_google_search_preference", ]); +const CB_PROVIDER_KEYS: ReadonlySet = new Set([ + "circuitBreakerFailureThreshold", + "circuitBreakerOpenDuration", + "circuitBreakerHalfOpenSuccessThreshold", +]); + function isClaudeProviderType(providerType: ProviderType): boolean { return providerType === "claude" || providerType === "claude-auth"; } @@ -1914,6 +1920,26 @@ export async function applyProviderBatchPatch( await publishProviderCacheInvalidation(); + const hasCbFieldChange = changedFields.some( + (f) => + f === "circuit_breaker_failure_threshold" || + f === "circuit_breaker_open_duration" || + f === "circuit_breaker_half_open_success_threshold" + ); + if (hasCbFieldChange) { + for (const id of effectiveProviderIds) { + try { + await deleteProviderCircuitConfig(id); + clearConfigCache(id); + } catch (error) { + logger.warn("applyProviderBatchPatch:cb_cache_invalidation_failed", { + providerId: id, + error: error instanceof Error ? error.message : String(error), + }); + } + } + } + const appliedAt = new Date(nowMs).toISOString(); const undoToken = createProviderPatchUndoToken(); const undoExpiresAtMs = nowMs + PROVIDER_PATCH_UNDO_TTL_SECONDS * 1000; @@ -2022,6 +2048,23 @@ export async function undoProviderPatch( await publishProviderCacheInvalidation(); } + const hasCbRevert = Object.values(snapshot.preimage).some((fields) => + Object.keys(fields).some((k) => CB_PROVIDER_KEYS.has(k)) + ); + if (hasCbRevert) { + for (const providerId of snapshot.providerIds) { + try { + await deleteProviderCircuitConfig(providerId); + clearConfigCache(providerId); + } catch (error) { + logger.warn("undoProviderPatch:cb_cache_invalidation_failed", { + providerId, + error: error instanceof Error ? error.message : String(error), + }); + } + } + } + return { ok: true, data: { diff --git a/src/app/[locale]/settings/providers/_components/batch-edit/build-patch-draft.ts b/src/app/[locale]/settings/providers/_components/batch-edit/build-patch-draft.ts index d9b2e2b7e..9fb8fb9cb 100644 --- a/src/app/[locale]/settings/providers/_components/batch-edit/build-patch-draft.ts +++ b/src/app/[locale]/settings/providers/_components/batch-edit/build-patch-draft.ts @@ -58,6 +58,20 @@ export function buildPatchDraftFromFormState( draft.allowed_models = { set: state.routing.allowedModels }; } } + if (dirtyFields.has("routing.allowedClients")) { + if (state.routing.allowedClients.length === 0) { + draft.allowed_clients = { clear: true }; + } else { + draft.allowed_clients = { set: state.routing.allowedClients }; + } + } + if (dirtyFields.has("routing.blockedClients")) { + if (state.routing.blockedClients.length === 0) { + draft.blocked_clients = { clear: true }; + } else { + draft.blocked_clients = { set: state.routing.blockedClients }; + } + } if (dirtyFields.has("routing.groupPriorities")) { const entries = Object.keys(state.routing.groupPriorities); if (entries.length === 0) { diff --git a/src/app/v1/_lib/proxy/forwarder.ts b/src/app/v1/_lib/proxy/forwarder.ts index bc161dd0e..4bb3ffc7a 100644 --- a/src/app/v1/_lib/proxy/forwarder.ts +++ b/src/app/v1/_lib/proxy/forwarder.ts @@ -77,6 +77,7 @@ const STANDARD_ENDPOINTS = [ "/v1/messages", "/v1/messages/count_tokens", "/v1/responses", + "/v1/responses/compact", "/v1/chat/completions", "/v1/models", ]; diff --git a/src/lib/provider-patch-contract.ts b/src/lib/provider-patch-contract.ts index 1e3c876d0..d80401f79 100644 --- a/src/lib/provider-patch-contract.ts +++ b/src/lib/provider-patch-contract.ts @@ -268,6 +268,9 @@ function isValidSetValue(field: ProviderBatchPatchField, value: unknown): boolea return isStringRecord(value); case "allowed_models": return Array.isArray(value) && value.every((model) => typeof model === "string"); + case "allowed_clients": + case "blocked_clients": + return Array.isArray(value) && value.every((v) => typeof v === "string"); case "anthropic_adaptive_thinking": return isAdaptiveThinkingConfig(value); default: diff --git a/tests/unit/actions/providers-batch-field-mapping.test.ts b/tests/unit/actions/providers-batch-field-mapping.test.ts index d304ef4e2..95cac0389 100644 --- a/tests/unit/actions/providers-batch-field-mapping.test.ts +++ b/tests/unit/actions/providers-batch-field-mapping.test.ts @@ -253,4 +253,69 @@ describe("batchUpdateProviders - advanced field mapping", () => { expect(result.ok).toBe(true); expect(updateProvidersBatchMock).toHaveBeenCalledTimes(1); }); + + it("should map allowed_clients with values correctly", async () => { + const { batchUpdateProviders } = await import("@/actions/providers"); + const result = await batchUpdateProviders({ + providerIds: [1, 2], + updates: { allowed_clients: ["client-a", "client-b"] }, + }); + + expect(result.ok).toBe(true); + expect(updateProvidersBatchMock).toHaveBeenCalledWith([1, 2], { + allowedClients: ["client-a", "client-b"], + }); + }); + + it("should map allowed_clients=null to repository allowedClients=null", async () => { + const { batchUpdateProviders } = await import("@/actions/providers"); + const result = await batchUpdateProviders({ + providerIds: [3], + updates: { allowed_clients: null }, + }); + + expect(result.ok).toBe(true); + expect(updateProvidersBatchMock).toHaveBeenCalledWith([3], { + allowedClients: null, + }); + }); + + it("should pass allowed_clients=[] as empty array", async () => { + const { batchUpdateProviders } = await import("@/actions/providers"); + const result = await batchUpdateProviders({ + providerIds: [1], + updates: { allowed_clients: [] }, + }); + + expect(result.ok).toBe(true); + expect(updateProvidersBatchMock).toHaveBeenCalledWith([1], { + allowedClients: [], + }); + }); + + it("should map blocked_clients with values correctly", async () => { + const { batchUpdateProviders } = await import("@/actions/providers"); + const result = await batchUpdateProviders({ + providerIds: [1, 2], + updates: { blocked_clients: ["bad-client"] }, + }); + + expect(result.ok).toBe(true); + expect(updateProvidersBatchMock).toHaveBeenCalledWith([1, 2], { + blockedClients: ["bad-client"], + }); + }); + + it("should map blocked_clients=null to repository blockedClients=null", async () => { + const { batchUpdateProviders } = await import("@/actions/providers"); + const result = await batchUpdateProviders({ + providerIds: [5], + updates: { blocked_clients: null }, + }); + + expect(result.ok).toBe(true); + expect(updateProvidersBatchMock).toHaveBeenCalledWith([5], { + blockedClients: null, + }); + }); }); diff --git a/tests/unit/actions/providers-patch-contract.test.ts b/tests/unit/actions/providers-patch-contract.test.ts index 6e93a065a..1d919f38d 100644 --- a/tests/unit/actions/providers-patch-contract.test.ts +++ b/tests/unit/actions/providers-patch-contract.test.ts @@ -125,6 +125,46 @@ describe("provider patch contract", () => { expect(result.error.field).toBe("model_redirects"); }); + it("accepts allowed_clients with string array", () => { + const result = normalizeProviderBatchPatchDraft({ + allowed_clients: { set: ["client-a", "client-b"] }, + }); + + expect(result.ok).toBe(true); + }); + + it("rejects allowed_clients with non-string array", () => { + const result = normalizeProviderBatchPatchDraft({ + allowed_clients: { set: [123] } as never, + }); + + expect(result.ok).toBe(false); + if (result.ok) return; + + expect(result.error.code).toBe(PROVIDER_PATCH_ERROR_CODES.INVALID_PATCH_SHAPE); + expect(result.error.field).toBe("allowed_clients"); + }); + + it("accepts blocked_clients with string array", () => { + const result = normalizeProviderBatchPatchDraft({ + blocked_clients: { set: ["bad-client"] }, + }); + + expect(result.ok).toBe(true); + }); + + it("rejects blocked_clients with non-string array", () => { + const result = normalizeProviderBatchPatchDraft({ + blocked_clients: { set: { not: "array" } } as never, + }); + + expect(result.ok).toBe(false); + if (result.ok) return; + + expect(result.error.code).toBe(PROVIDER_PATCH_ERROR_CODES.INVALID_PATCH_SHAPE); + expect(result.error.field).toBe("blocked_clients"); + }); + it("rejects invalid thinking budget string values", () => { const result = normalizeProviderBatchPatchDraft({ anthropic_thinking_budget_preference: { diff --git a/tests/unit/proxy/proxy-forwarder-endpoint-audit.test.ts b/tests/unit/proxy/proxy-forwarder-endpoint-audit.test.ts index 0a3e23e53..8b4a5f4f9 100644 --- a/tests/unit/proxy/proxy-forwarder-endpoint-audit.test.ts +++ b/tests/unit/proxy/proxy-forwarder-endpoint-audit.test.ts @@ -742,4 +742,47 @@ describe("ProxyForwarder - endpoint audit", () => { // endpointFilterStats should be undefined when stats call fails expect(exhaustedItem!.endpointFilterStats).toBeUndefined(); }); + + test("/v1/responses/compact should use endpoint pool (not MCP path)", async () => { + const session = createSession(new URL("https://example.com/v1/responses/compact")); + const provider = createProvider({ providerType: "claude", providerVendorId: 123 }); + session.setProvider(provider); + + mocks.getPreferredProviderEndpoints.mockResolvedValue([ + makeEndpoint({ + id: 77, + vendorId: 123, + providerType: provider.providerType, + url: "https://api.example.com/v1/responses/compact", + }), + ]); + + 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 response = await ProxyForwarder.send(session); + expect(response.status).toBe(200); + + expect(mocks.getPreferredProviderEndpoints).toHaveBeenCalled(); + + const chain = session.getProviderChain(); + expect(chain).toHaveLength(1); + expect(chain[0]).toEqual( + expect.objectContaining({ + reason: "request_success", + endpointId: 77, + }) + ); + }); }); diff --git a/tests/unit/settings/providers/build-patch-draft.test.ts b/tests/unit/settings/providers/build-patch-draft.test.ts index c1421e6c3..b14f4e41c 100644 --- a/tests/unit/settings/providers/build-patch-draft.test.ts +++ b/tests/unit/settings/providers/build-patch-draft.test.ts @@ -15,6 +15,8 @@ function createBatchState(): ProviderFormState { preserveClientIp: false, modelRedirects: {}, allowedModels: [], + allowedClients: [], + blockedClients: [], priority: 0, groupPriorities: {}, weight: 1, @@ -644,4 +646,44 @@ describe("buildPatchDraftFromFormState", () => { expect(draft.limit_concurrent_sessions).toEqual({ set: 20 }); }); + + // --- Client restrictions --- + + it("clears allowedClients when dirty and empty array", () => { + const state = createBatchState(); + const dirty = new Set(["routing.allowedClients"]); + + const draft = buildPatchDraftFromFormState(state, dirty); + + expect(draft.allowed_clients).toEqual({ clear: true }); + }); + + it("sets allowedClients when dirty and non-empty", () => { + const state = createBatchState(); + state.routing.allowedClients = ["client-a", "client-b"]; + const dirty = new Set(["routing.allowedClients"]); + + const draft = buildPatchDraftFromFormState(state, dirty); + + expect(draft.allowed_clients).toEqual({ set: ["client-a", "client-b"] }); + }); + + it("clears blockedClients when dirty and empty array", () => { + const state = createBatchState(); + const dirty = new Set(["routing.blockedClients"]); + + const draft = buildPatchDraftFromFormState(state, dirty); + + expect(draft.blocked_clients).toEqual({ clear: true }); + }); + + it("sets blockedClients when dirty and non-empty", () => { + const state = createBatchState(); + state.routing.blockedClients = ["bad-client"]; + const dirty = new Set(["routing.blockedClients"]); + + const draft = buildPatchDraftFromFormState(state, dirty); + + expect(draft.blocked_clients).toEqual({ set: ["bad-client"] }); + }); });