feat(cowork-service): add budget policy proxy routes
This commit is contained in:
parent
be2f29d1b8
commit
a7468eaa3f
@ -108,4 +108,99 @@ describe('usage proxy routes', () => {
|
||||
expect(res.statusCode).toBe(502);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/usage/budget-policy', () => {
|
||||
it('loads active daily and monthly product policies', async () => {
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => [
|
||||
{ id: 'day_1', period: 'daily', budgetUsd: 5, modelAllowlist: ['gpt-4o-mini'] },
|
||||
{ id: 'month_1', period: 'monthly', budgetUsd: 50, modelAllowlist: ['gpt-4o-mini'] },
|
||||
],
|
||||
});
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/usage/budget-policy',
|
||||
headers: { authorization: 'Bearer test-token' },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.payload);
|
||||
expect(body.dailyBudgetUsd).toBe(5);
|
||||
expect(body.monthlyBudgetUsd).toBe(50);
|
||||
expect((mockFetch.mock.calls[0][1].headers as Record<string, string>).authorization).toBe(
|
||||
'Bearer test-token'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /api/usage/budget-policy', () => {
|
||||
it('creates daily and monthly policies when missing', async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => [],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ id: 'day_1', period: 'daily', budgetUsd: 10 }),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ id: 'month_1', period: 'monthly', budgetUsd: 200 }),
|
||||
});
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/usage/budget-policy',
|
||||
headers: { authorization: 'Bearer admin-token' },
|
||||
payload: {
|
||||
dailyBudgetUsd: 10,
|
||||
monthlyBudgetUsd: 200,
|
||||
modelAllowlist: ['gpt-4o-mini', 'claude-sonnet-4-20250514'],
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.payload);
|
||||
expect(body.dailyBudgetUsd).toBe(10);
|
||||
expect(body.monthlyBudgetUsd).toBe(200);
|
||||
expect(mockFetch.mock.calls[1][1].method).toBe('POST');
|
||||
expect(mockFetch.mock.calls[2][1].method).toBe('POST');
|
||||
});
|
||||
|
||||
it('updates existing policies when present', async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => [
|
||||
{ id: 'day_1', period: 'daily', budgetUsd: 5 },
|
||||
{ id: 'month_1', period: 'monthly', budgetUsd: 50 },
|
||||
],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ id: 'day_1', period: 'daily', budgetUsd: 15 }),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ id: 'month_1', period: 'monthly', budgetUsd: 150 }),
|
||||
});
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/usage/budget-policy',
|
||||
payload: {
|
||||
dailyBudgetUsd: 15,
|
||||
monthlyBudgetUsd: 150,
|
||||
modelAllowlist: ['gpt-4o-mini'],
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(mockFetch.mock.calls[1][1].method).toBe('PATCH');
|
||||
expect(mockFetch.mock.calls[2][1].method).toBe('PATCH');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -9,7 +9,90 @@ import type { FastifyInstance } from 'fastify';
|
||||
import { config } from '../../lib/config.js';
|
||||
import { PRODUCT_ID } from '../../lib/product-config.js';
|
||||
import { getUserId } from '../../lib/request-context.js';
|
||||
import { UsageSummaryQuerySchema, CheckLimitsSchema } from './types.js';
|
||||
import {
|
||||
BudgetPolicyPayloadSchema,
|
||||
CheckLimitsSchema,
|
||||
UsageSummaryQuerySchema,
|
||||
type BudgetPolicyPayload,
|
||||
} from './types.js';
|
||||
|
||||
type PlatformBudgetPolicy = {
|
||||
id: string;
|
||||
period: 'daily' | 'monthly';
|
||||
budgetUsd: number;
|
||||
modelAllowlist?: string[];
|
||||
scopeType: string;
|
||||
scopeId: string;
|
||||
status?: string;
|
||||
};
|
||||
|
||||
function buildProxyHeaders(
|
||||
req: import('fastify').FastifyRequest,
|
||||
contentType = false
|
||||
): Record<string, string> {
|
||||
const headers: Record<string, string> = {
|
||||
'x-product-id': PRODUCT_ID,
|
||||
'x-request-id': req.id,
|
||||
};
|
||||
if (contentType) headers['Content-Type'] = 'application/json';
|
||||
const auth = req.headers.authorization;
|
||||
if (typeof auth === 'string') headers.authorization = auth;
|
||||
return headers;
|
||||
}
|
||||
|
||||
async function listBudgetPolicies(
|
||||
platformUrl: string,
|
||||
req: import('fastify').FastifyRequest
|
||||
): Promise<PlatformBudgetPolicy[]> {
|
||||
const params = new URLSearchParams({
|
||||
scopeType: 'product',
|
||||
scopeId: PRODUCT_ID,
|
||||
status: 'active',
|
||||
limit: '20',
|
||||
});
|
||||
const res = await fetch(`${platformUrl}/ai-budgets/policies?${params.toString()}`, {
|
||||
headers: buildProxyHeaders(req),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`Platform returned ${res.status}`);
|
||||
}
|
||||
return (await res.json()) as PlatformBudgetPolicy[];
|
||||
}
|
||||
|
||||
async function upsertPolicy(
|
||||
platformUrl: string,
|
||||
req: import('fastify').FastifyRequest,
|
||||
existing: PlatformBudgetPolicy | undefined,
|
||||
period: 'daily' | 'monthly',
|
||||
payload: BudgetPolicyPayload
|
||||
): Promise<PlatformBudgetPolicy> {
|
||||
const budgetUsd = period === 'daily' ? payload.dailyBudgetUsd : payload.monthlyBudgetUsd;
|
||||
const body = {
|
||||
scopeType: 'product',
|
||||
scopeId: PRODUCT_ID,
|
||||
name: `Claw Cowork ${period === 'daily' ? 'Daily' : 'Monthly'} Budget`,
|
||||
period,
|
||||
budgetUsd,
|
||||
softThreshold: 0.8,
|
||||
hardThreshold: 1,
|
||||
modelAllowlist: payload.modelAllowlist,
|
||||
};
|
||||
|
||||
const targetUrl = existing
|
||||
? `${platformUrl}/ai-budgets/policies/${encodeURIComponent(existing.id)}`
|
||||
: `${platformUrl}/ai-budgets/policies`;
|
||||
const method = existing ? 'PATCH' : 'POST';
|
||||
|
||||
const res = await fetch(targetUrl, {
|
||||
method,
|
||||
headers: buildProxyHeaders(req, true),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`Platform returned ${res.status}`);
|
||||
}
|
||||
return (await res.json()) as PlatformBudgetPolicy;
|
||||
}
|
||||
|
||||
export async function usageRoutes(app: FastifyInstance) {
|
||||
const platformUrl = config.PLATFORM_SERVICE_URL;
|
||||
@ -29,11 +112,8 @@ export async function usageRoutes(app: FastifyInstance) {
|
||||
const res = await fetch(
|
||||
`${platformUrl}/usage/summary?userId=${encodeURIComponent(userId)}&days=${days}`,
|
||||
{
|
||||
headers: {
|
||||
'x-product-id': PRODUCT_ID,
|
||||
'x-request-id': req.id,
|
||||
},
|
||||
},
|
||||
headers: buildProxyHeaders(req),
|
||||
}
|
||||
);
|
||||
if (!res.ok) {
|
||||
reply.code(res.status);
|
||||
@ -61,11 +141,7 @@ export async function usageRoutes(app: FastifyInstance) {
|
||||
try {
|
||||
const res = await fetch(`${platformUrl}/usage/check-limits`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-product-id': PRODUCT_ID,
|
||||
'x-request-id': req.id,
|
||||
},
|
||||
headers: buildProxyHeaders(req, true),
|
||||
body: JSON.stringify({ userId, plan }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
@ -79,4 +155,60 @@ export async function usageRoutes(app: FastifyInstance) {
|
||||
return { error: 'Platform-service unavailable' };
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/usage/budget-policy', async (req, reply) => {
|
||||
try {
|
||||
const policies = await listBudgetPolicies(platformUrl, req);
|
||||
const daily = policies.find(policy => policy.period === 'daily');
|
||||
const monthly = policies.find(policy => policy.period === 'monthly');
|
||||
|
||||
return {
|
||||
dailyBudgetUsd: daily?.budgetUsd ?? null,
|
||||
monthlyBudgetUsd: monthly?.budgetUsd ?? null,
|
||||
modelAllowlist: monthly?.modelAllowlist ?? daily?.modelAllowlist ?? [],
|
||||
policies,
|
||||
};
|
||||
} catch (err) {
|
||||
req.log.warn({ err }, 'Failed to load budget policy from platform-service');
|
||||
reply.code(502);
|
||||
return { error: 'Platform-service unavailable' };
|
||||
}
|
||||
});
|
||||
|
||||
app.put('/api/usage/budget-policy', async (req, reply) => {
|
||||
const parsed = BudgetPolicyPayloadSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
reply.code(400);
|
||||
return { error: 'Invalid body', details: parsed.error.issues };
|
||||
}
|
||||
|
||||
try {
|
||||
const existing = await listBudgetPolicies(platformUrl, req);
|
||||
const daily = await upsertPolicy(
|
||||
platformUrl,
|
||||
req,
|
||||
existing.find(policy => policy.period === 'daily'),
|
||||
'daily',
|
||||
parsed.data
|
||||
);
|
||||
const monthly = await upsertPolicy(
|
||||
platformUrl,
|
||||
req,
|
||||
existing.find(policy => policy.period === 'monthly'),
|
||||
'monthly',
|
||||
parsed.data
|
||||
);
|
||||
|
||||
return {
|
||||
dailyBudgetUsd: daily.budgetUsd,
|
||||
monthlyBudgetUsd: monthly.budgetUsd,
|
||||
modelAllowlist: monthly.modelAllowlist ?? daily.modelAllowlist ?? [],
|
||||
policies: [daily, monthly],
|
||||
};
|
||||
} catch (err) {
|
||||
req.log.warn({ err }, 'Failed to save budget policy to platform-service');
|
||||
reply.code(502);
|
||||
return { error: 'Platform-service unavailable' };
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@ -12,5 +12,12 @@ export const CheckLimitsSchema = z.object({
|
||||
plan: z.string().default('free'),
|
||||
});
|
||||
|
||||
export const BudgetPolicyPayloadSchema = z.object({
|
||||
dailyBudgetUsd: z.number().min(0.01),
|
||||
monthlyBudgetUsd: z.number().min(0.01),
|
||||
modelAllowlist: z.array(z.string().min(1)).default([]),
|
||||
});
|
||||
|
||||
export type UsageSummaryQuery = z.infer<typeof UsageSummaryQuerySchema>;
|
||||
export type CheckLimitsInput = z.infer<typeof CheckLimitsSchema>;
|
||||
export type BudgetPolicyPayload = z.infer<typeof BudgetPolicyPayloadSchema>;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user