Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions src/actions/providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1593,6 +1593,12 @@ const GEMINI_ONLY_FIELDS: ReadonlySet<ProviderBatchPatchField> = new Set([
"gemini_google_search_preference",
]);

const CB_PROVIDER_KEYS: ReadonlySet<string> = new Set([
"circuitBreakerFailureThreshold",
"circuitBreakerOpenDuration",
"circuitBreakerHalfOpenSuccessThreshold",
]);

function isClaudeProviderType(providerType: ProviderType): boolean {
return providerType === "claude" || providerType === "claude-auth";
}
Expand Down Expand Up @@ -1914,6 +1920,26 @@ export async function applyProviderBatchPatch(

await publishProviderCacheInvalidation();

const hasCbFieldChange = changedFields.some(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[HIGH] [TEST-MISSING-CRITICAL] Circuit breaker cache invalidation lacks unit test coverage

Why this is a problem: applyProviderBatchPatch/undoProviderPatch now invalidate Redis + in-memory circuit breaker config caches when CB fields change (src/actions/providers.ts:1917-1935, src/actions/providers.ts:2045-2065). CLAUDE.md requires: "Test Coverage - All new features must have unit test coverage of at least 80%". Without targeted unit tests, regressions in the CB-field detection or invalidation loop can ship unnoticed.

Suggested fix:

// tests/unit/actions/providers-apply-engine.test.ts
it("should invalidate circuit breaker config caches when CB fields change", async () => {
  findAllProvidersFreshMock.mockResolvedValue([makeProvider(1), makeProvider(2)]);
  updateProvidersBatchMock.mockResolvedValue(2);

  const { clearConfigCache } = await import("@/lib/circuit-breaker");

  await setupPreviewAndApply([1, 2], { circuit_breaker_failure_threshold: { set: 10 } });

  expect(redisDelMock).toHaveBeenCalledWith("circuit_breaker:config:1");
  expect(redisDelMock).toHaveBeenCalledWith("circuit_breaker:config:2");
  expect(clearConfigCache).toHaveBeenCalledWith(1);
  expect(clearConfigCache).toHaveBeenCalledWith(2);
});
// tests/unit/actions/providers-undo-engine.test.ts
it("should invalidate circuit breaker config caches on undo when CB fields were reverted", async () => {
  const providers = [makeProvider(1), makeProvider(2)];
  const { undoToken, operationId, undoProviderPatch } = await setupPreviewApplyAndGetUndo(
    providers,
    [1, 2],
    { circuit_breaker_open_duration: { set: 1234 } }
  );

  const { clearConfigCache } = await import("@/lib/circuit-breaker");
  redisMocks.del.mockClear();
  clearConfigCache.mockClear();

  updateProvidersBatchMock.mockResolvedValue(2);

  await undoProviderPatch({ undoToken, operationId });

  expect(redisMocks.del).toHaveBeenCalledWith("circuit_breaker:config:1");
  expect(redisMocks.del).toHaveBeenCalledWith("circuit_breaker:config:2");
  expect(clearConfigCache).toHaveBeenCalledWith(1);
  expect(clearConfigCache).toHaveBeenCalledWith(2);
});

(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;
Expand Down Expand Up @@ -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: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
1 change: 1 addition & 0 deletions src/app/v1/_lib/proxy/forwarder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ const STANDARD_ENDPOINTS = [
"/v1/messages",
"/v1/messages/count_tokens",
"/v1/responses",
"/v1/responses/compact",
"/v1/chat/completions",
"/v1/models",
];
Expand Down
3 changes: 3 additions & 0 deletions src/lib/provider-patch-contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
65 changes: 65 additions & 0 deletions tests/unit/actions/providers-batch-field-mapping.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
});
});
40 changes: 40 additions & 0 deletions tests/unit/actions/providers-patch-contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
43 changes: 43 additions & 0 deletions tests/unit/proxy/proxy-forwarder-endpoint-audit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
})
);
});
});
42 changes: 42 additions & 0 deletions tests/unit/settings/providers/build-patch-draft.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ function createBatchState(): ProviderFormState {
preserveClientIp: false,
modelRedirects: {},
allowedModels: [],
allowedClients: [],
blockedClients: [],
priority: 0,
groupPriorities: {},
weight: 1,
Expand Down Expand Up @@ -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"] });
});
});
Loading