feat(cowork-service): H.6 usage/budget proxy routes
Add usage query routes to cowork-service that proxy to platform-service: - GET /api/usage/summary — user's aggregated usage with days filter - POST /api/usage/check-limits — check if user is within plan limits - modules/usage/types.ts — UsageSummaryQuerySchema, CheckLimitsSchema (Zod) - modules/usage/routes.ts — proxy routes with error handling - server.ts — register usageRoutes (5 route modules total) - server.test.ts — add usage routes mock, update register count to 5 57 tests passing, 9 test files, typecheck clean
This commit is contained in:
parent
dc27ee9f21
commit
cd5483c95b
82
services/cowork-service/src/modules/usage/routes.ts
Normal file
82
services/cowork-service/src/modules/usage/routes.ts
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
/**
|
||||||
|
* Usage/budget proxy routes — forward queries to platform-service /usage endpoints.
|
||||||
|
*
|
||||||
|
* Lets the Tauri desktop check AI budget consumption and plan limits
|
||||||
|
* via cowork-service, which proxies to platform-service.
|
||||||
|
*/
|
||||||
|
|
||||||
|
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';
|
||||||
|
|
||||||
|
export async function usageRoutes(app: FastifyInstance) {
|
||||||
|
const platformUrl = config.PLATFORM_SERVICE_URL;
|
||||||
|
|
||||||
|
// GET /api/usage/summary — proxy to platform-service GET /usage/summary
|
||||||
|
app.get('/api/usage/summary', async (req, reply) => {
|
||||||
|
const parsed = UsageSummaryQuerySchema.safeParse(req.query);
|
||||||
|
if (!parsed.success) {
|
||||||
|
reply.code(400);
|
||||||
|
return { error: 'Invalid query', details: parsed.error.issues };
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = getUserId(req);
|
||||||
|
const { days } = parsed.data;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`${platformUrl}/usage/summary?userId=${encodeURIComponent(userId)}&days=${days}`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'x-product-id': PRODUCT_ID,
|
||||||
|
'x-request-id': req.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!res.ok) {
|
||||||
|
reply.code(res.status);
|
||||||
|
return { error: `Platform returned ${res.status}` };
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
} catch (err) {
|
||||||
|
req.log.warn({ err }, 'Failed to proxy usage summary to platform-service');
|
||||||
|
reply.code(502);
|
||||||
|
return { error: 'Platform-service unavailable' };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/usage/check-limits — proxy to platform-service POST /usage/check-limits
|
||||||
|
app.post('/api/usage/check-limits', async (req, reply) => {
|
||||||
|
const parsed = CheckLimitsSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
reply.code(400);
|
||||||
|
return { error: 'Invalid body', details: parsed.error.issues };
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = getUserId(req);
|
||||||
|
const { plan } = parsed.data;
|
||||||
|
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ userId, plan }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
reply.code(res.status);
|
||||||
|
return { error: `Platform returned ${res.status}` };
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
} catch (err) {
|
||||||
|
req.log.warn({ err }, 'Failed to proxy limit check to platform-service');
|
||||||
|
reply.code(502);
|
||||||
|
return { error: 'Platform-service unavailable' };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
16
services/cowork-service/src/modules/usage/types.ts
Normal file
16
services/cowork-service/src/modules/usage/types.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
/**
|
||||||
|
* Usage/budget query types for cowork-service proxy routes.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export const UsageSummaryQuerySchema = z.object({
|
||||||
|
days: z.coerce.number().int().min(1).max(365).default(30),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const CheckLimitsSchema = z.object({
|
||||||
|
plan: z.string().default('free'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type UsageSummaryQuery = z.infer<typeof UsageSummaryQuerySchema>;
|
||||||
|
export type CheckLimitsInput = z.infer<typeof CheckLimitsSchema>;
|
||||||
@ -71,6 +71,7 @@ vi.mock('./lib/llm-router.js', () => ({
|
|||||||
}));
|
}));
|
||||||
vi.mock('./modules/llm/routes.js', () => ({ llmRoutes: vi.fn() }));
|
vi.mock('./modules/llm/routes.js', () => ({ llmRoutes: vi.fn() }));
|
||||||
vi.mock('./modules/audit/routes.js', () => ({ auditRoutes: vi.fn() }));
|
vi.mock('./modules/audit/routes.js', () => ({ auditRoutes: vi.fn() }));
|
||||||
|
vi.mock('./modules/usage/routes.js', () => ({ usageRoutes: vi.fn() }));
|
||||||
|
|
||||||
describe('cowork-service bootstrap', () => {
|
describe('cowork-service bootstrap', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@ -90,9 +91,9 @@ describe('cowork-service bootstrap', () => {
|
|||||||
expect(opts.version).toBe('0.1.0');
|
expect(opts.version).toBe('0.1.0');
|
||||||
expect(opts.readiness).toBe(true);
|
expect(opts.readiness).toBe(true);
|
||||||
|
|
||||||
// JWT context + health + task + llm + audit routes = 4 register calls + 1 JWT
|
// JWT context + health + task + llm + audit + usage routes = 5 register calls + 1 JWT
|
||||||
expect(registerOptionalJwtContextMock).toHaveBeenCalledOnce();
|
expect(registerOptionalJwtContextMock).toHaveBeenCalledOnce();
|
||||||
expect(appMock.register).toHaveBeenCalledTimes(4);
|
expect(appMock.register).toHaveBeenCalledTimes(5);
|
||||||
expect(startServiceMock).toHaveBeenCalledWith(appMock, { port: 4009, host: '0.0.0.0' });
|
expect(startServiceMock).toHaveBeenCalledWith(appMock, { port: 4009, host: '0.0.0.0' });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -26,6 +26,7 @@ import { getFlushScheduler } from './lib/flush-scheduler.js';
|
|||||||
import { initLlmRouter } from './lib/llm-router.js';
|
import { initLlmRouter } from './lib/llm-router.js';
|
||||||
import { llmRoutes } from './modules/llm/routes.js';
|
import { llmRoutes } from './modules/llm/routes.js';
|
||||||
import { auditRoutes } from './modules/audit/routes.js';
|
import { auditRoutes } from './modules/audit/routes.js';
|
||||||
|
import { usageRoutes } from './modules/usage/routes.js';
|
||||||
import type { JwtPayload } from './lib/request-context.js';
|
import type { JwtPayload } from './lib/request-context.js';
|
||||||
|
|
||||||
const jwtSecret = new TextEncoder().encode(config.JWT_SECRET);
|
const jwtSecret = new TextEncoder().encode(config.JWT_SECRET);
|
||||||
@ -56,6 +57,7 @@ await app.register(healthRoutes);
|
|||||||
await app.register(taskRoutes);
|
await app.register(taskRoutes);
|
||||||
await app.register(llmRoutes);
|
await app.register(llmRoutes);
|
||||||
await app.register(auditRoutes);
|
await app.register(auditRoutes);
|
||||||
|
await app.register(usageRoutes);
|
||||||
|
|
||||||
// Bootstrap endpoint (same pattern as FlowMonk, ActionTrail, etc.)
|
// Bootstrap endpoint (same pattern as FlowMonk, ActionTrail, etc.)
|
||||||
app.get('/api/bootstrap', async () => ({
|
app.get('/api/bootstrap', async () => ({
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user