feat(billing): add usage increment API + wire hand/pipeline execution tracking

Server side:
- POST /api/v1/billing/usage/increment endpoint with dimension whitelist
  (hand_executions, pipeline_runs, relay_requests) and count validation (1-100)
- Returns updated usage quota after increment

Desktop side:
- New saas-billing.ts mixin with incrementUsageDimension() and
  reportUsageFireAndForget() (non-blocking, safe for finally blocks)
- handStore.triggerHand: reports hand_executions after successful run
- PipelinesPanel.handleRunComplete: reports pipeline_runs on completion
- SaaSClient type declarations for new billing methods

Billing pipeline now covers all three dimensions:
  relay_requests  → relay handler (server-side, real-time)
  hand_executions → handStore (client-side, fire-and-forget)
  pipeline_runs   → PipelinesPanel (client-side, fire-and-forget)
This commit is contained in:
iven
2026-04-02 02:02:59 +08:00
parent 11e3d37468
commit 837abec48a
6 changed files with 130 additions and 0 deletions

View File

@@ -29,6 +29,7 @@ import {
formatInputType,
} from '../lib/pipeline-client';
import { useToast } from './ui/Toast';
import { saasClient } from '../lib/saas-client';
// === Category Badge Component ===
@@ -431,6 +432,13 @@ export function PipelinesPanel() {
if (result.status === 'completed') {
toast('Pipeline 执行完成', 'success');
setRunResult({ result, pipeline: selectedPipeline! });
// Report pipeline execution to billing (fire-and-forget)
try {
if (saasClient.isAuthenticated()) {
saasClient.reportUsageFireAndForget('pipeline_runs');
}
} catch { /* billing reporting must never block */ }
} else {
toast(`Pipeline 执行失败: ${result.error}`, 'error');
}

View File

@@ -0,0 +1,60 @@
/**
* SaaS Billing Methods — Mixin
*
* Installs billing-related methods (usage increment, quota check) onto
* SaaSClient.prototype. Uses the same mixin pattern as saas-telemetry.ts.
*/
export interface UsageIncrementResult {
dimension: string;
incremented: number;
usage: {
relay_requests: number;
hand_executions: number;
pipeline_runs: number;
input_tokens: number;
output_tokens: number;
[key: string]: unknown;
};
}
export function installBillingMethods(ClientClass: { prototype: any }): void {
const proto = ClientClass.prototype;
/**
* Report a usage increment for a specific dimension.
*
* Called after hand execution, pipeline run, or other metered operations.
* Non-blocking — failures are logged but don't affect the caller.
*
* @param dimension - One of: hand_executions, pipeline_runs, relay_requests
* @param count - How many units to increment (default 1, max 100)
*/
proto.incrementUsageDimension = async function (
this: { request<T>(method: string, path: string, body?: unknown): Promise<T> },
dimension: string,
count: number = 1,
): Promise<UsageIncrementResult> {
return this.request<UsageIncrementResult>(
'POST',
'/api/v1/billing/usage/increment',
{ dimension, count },
);
};
/**
* Fire-and-forget version of incrementUsageDimension.
* Logs errors but never throws — safe to call in finally blocks.
*/
proto.reportUsageFireAndForget = function (
this: { incrementUsageDimension(dimension: string, count?: number): Promise<UsageIncrementResult> },
dimension: string,
count: number = 1,
): void {
this.incrementUsageDimension(dimension, count).catch((err: unknown) => {
// Non-fatal: billing reporting failure must never block user operations
const msg = err instanceof Error ? err.message : String(err);
console.warn(`[Billing] Failed to report ${dimension} usage (+${count}): ${msg}`);
});
};
}

View File

@@ -132,6 +132,7 @@ import { installAdminMethods } from './saas-admin';
import { installRelayMethods } from './saas-relay';
import { installPromptMethods } from './saas-prompt';
import { installTelemetryMethods } from './saas-telemetry';
import { installBillingMethods } from './saas-billing';
// === Client Implementation ===
@@ -411,6 +412,7 @@ installAdminMethods(SaaSClient);
installRelayMethods(SaaSClient);
installPromptMethods(SaaSClient);
installTelemetryMethods(SaaSClient);
installBillingMethods(SaaSClient);
// === API Method Type Declarations ===
// These methods are installed at runtime by installXxxMethods() in saas-*.ts.
@@ -516,6 +518,10 @@ export interface SaaSClient {
timestamp: string;
}>;
}): Promise<{ accepted: number; total: number }>;
// --- Billing (saas-billing.ts) ---
incrementUsageDimension(dimension: string, count?: number): Promise<import('./saas-billing').UsageIncrementResult>;
reportUsageFireAndForget(dimension: string, count?: number): void;
}
// === Singleton ===

View File

@@ -8,6 +8,7 @@ import { create } from 'zustand';
import type { GatewayClient } from '../lib/gateway-client';
import { canAutoExecute, getAutonomyManager } from '../lib/autonomy-manager';
import type { AutonomyDecision } from '../lib/autonomy-manager';
import { saasClient } from '../lib/saas-client';
// === Re-exported Types (from gatewayStore for compatibility) ===
@@ -355,6 +356,13 @@ export const useHandStore = create<HandStore>((set, get) => ({
// Refresh hands to update status
await get().loadHands();
// Report hand execution to billing (fire-and-forget, non-blocking)
try {
if (saasClient.isAuthenticated()) {
saasClient.reportUsageFireAndForget('hand_executions');
}
} catch { /* billing reporting must never block */ }
return run;
} catch (err: unknown) {
set({ error: err instanceof Error ? err.message : String(err) });