@cartanova/qgrid-cli 1.0.0 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,181 @@
1
+ /**
2
+ * OAuth 유틸 — Claude Code 소스의 OAuth 플로우 그대로 재현
3
+ */
4
+ import { createHash, randomBytes } from "node:crypto";
5
+
6
+ import type { UsageResponse } from "./qgrid.types";
7
+
8
+ const CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
9
+ const AUTHORIZE_URL = "https://claude.com/cai/oauth/authorize";
10
+ const TOKEN_URL = "https://platform.claude.com/v1/oauth/token";
11
+
12
+ // login 시 사용 (authorize URL)
13
+ const ALL_SCOPES = [
14
+ "org:create_api_key",
15
+ "user:profile",
16
+ "user:inference",
17
+ "user:sessions:claude_code",
18
+ "user:mcp_servers",
19
+ "user:file_upload",
20
+ ];
21
+
22
+ // refresh 시 사용 (org:create_api_key 제외)
23
+ const REFRESH_SCOPES = [
24
+ "user:profile",
25
+ "user:inference",
26
+ "user:sessions:claude_code",
27
+ "user:mcp_servers",
28
+ "user:file_upload",
29
+ ];
30
+
31
+ function base64URLEncode(buffer: Buffer): string {
32
+ return buffer.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
33
+ }
34
+
35
+ export function generatePKCE(): { codeVerifier: string; codeChallenge: string; state: string } {
36
+ const codeVerifier = base64URLEncode(randomBytes(32));
37
+ const codeChallenge = base64URLEncode(createHash("sha256").update(codeVerifier).digest());
38
+ const state = base64URLEncode(randomBytes(32));
39
+ return { codeVerifier, codeChallenge, state };
40
+ }
41
+
42
+ export function buildAuthUrl(codeChallenge: string, state: string, redirectUri: string): string {
43
+ const url = new URL(AUTHORIZE_URL);
44
+ url.searchParams.append("code", "true");
45
+ url.searchParams.append("client_id", CLIENT_ID);
46
+ url.searchParams.append("response_type", "code");
47
+ url.searchParams.append("redirect_uri", redirectUri);
48
+ url.searchParams.append("scope", ALL_SCOPES.join(" "));
49
+ url.searchParams.append("code_challenge", codeChallenge);
50
+ url.searchParams.append("code_challenge_method", "S256");
51
+ url.searchParams.append("state", state);
52
+ return url.toString();
53
+ }
54
+
55
+ export type OAuthTokens = {
56
+ accessToken: string;
57
+ refreshToken: string;
58
+ expiresAt: number;
59
+ scope: string;
60
+ accountUuid?: string;
61
+ emailAddress?: string;
62
+ };
63
+
64
+ export async function exchangeCodeForTokens(
65
+ code: string,
66
+ codeVerifier: string,
67
+ state: string,
68
+ redirectUri: string,
69
+ ): Promise<OAuthTokens> {
70
+ const res = await fetch(TOKEN_URL, {
71
+ method: "POST",
72
+ headers: { "Content-Type": "application/json" },
73
+ body: JSON.stringify({
74
+ grant_type: "authorization_code",
75
+ code,
76
+ redirect_uri: redirectUri,
77
+ client_id: CLIENT_ID,
78
+ code_verifier: codeVerifier,
79
+ state,
80
+ }),
81
+ });
82
+ if (!res.ok) {
83
+ const text = await res.text();
84
+ throw new Error(`Token exchange failed (${res.status}): ${text}`);
85
+ }
86
+
87
+ const data = (await res.json()) as {
88
+ access_token: string;
89
+ refresh_token: string;
90
+ expires_in: number;
91
+ scope: string;
92
+ account?: { uuid: string; email_address: string };
93
+ };
94
+
95
+ return {
96
+ accessToken: data.access_token,
97
+ refreshToken: data.refresh_token,
98
+ expiresAt: Date.now() + data.expires_in * 1000,
99
+ scope: data.scope,
100
+ accountUuid: data.account?.uuid,
101
+ emailAddress: data.account?.email_address,
102
+ };
103
+ }
104
+
105
+ export async function refreshAccessToken(refreshToken: string): Promise<OAuthTokens> {
106
+ const res = await fetch(TOKEN_URL, {
107
+ method: "POST",
108
+ headers: { "Content-Type": "application/json" },
109
+ body: JSON.stringify({
110
+ grant_type: "refresh_token",
111
+ refresh_token: refreshToken,
112
+ client_id: CLIENT_ID,
113
+ scope: REFRESH_SCOPES.join(" "),
114
+ }),
115
+ });
116
+ if (!res.ok) {
117
+ const text = await res.text();
118
+ throw new Error(`Token refresh failed (${res.status}): ${text}`);
119
+ }
120
+ const data = (await res.json()) as {
121
+ access_token: string;
122
+ refresh_token: string;
123
+ expires_in: number;
124
+ scope: string;
125
+ };
126
+
127
+ return {
128
+ accessToken: data.access_token,
129
+ refreshToken: data.refresh_token ?? refreshToken,
130
+ expiresAt: Date.now() + data.expires_in * 1000,
131
+ scope: data.scope,
132
+ };
133
+ }
134
+
135
+ const usageCache: Record<string, { data: UsageResponse; cachedAt: number }> = {};
136
+ const USAGE_API_CACHE_TTL = 60_000; // 1분
137
+
138
+ export async function fetchUsage(accessToken: string): Promise<UsageResponse> {
139
+ const cacheKey = accessToken.slice(-10);
140
+ const cached = usageCache[cacheKey];
141
+ if (cached && Date.now() - cached.cachedAt < USAGE_API_CACHE_TTL) {
142
+ console.log(`[usage] cache hit: ${cacheKey}`);
143
+ return cached.data;
144
+ }
145
+
146
+ const res = await fetch("https://api.anthropic.com/api/oauth/usage", {
147
+ headers: {
148
+ Authorization: `Bearer ${accessToken}`,
149
+ "Content-Type": "application/json",
150
+ "anthropic-beta": "oauth-2025-04-20",
151
+ },
152
+ signal: AbortSignal.timeout(5000),
153
+ });
154
+ if (!res.ok) {
155
+ const text = await res.text();
156
+
157
+ // 파싱 시도해서 에러 메시지 추출
158
+ let errorMessage = `Error ${res.status}`;
159
+ try {
160
+ const parsed = JSON.parse(text);
161
+ errorMessage = parsed.error?.message ?? errorMessage;
162
+ } catch {
163
+ // 파싱 안 되면 기본 메시지
164
+ }
165
+
166
+ console.warn(`[usage] ${res.status}: ${errorMessage}`);
167
+
168
+ // 이전 성공 캐시 있으면 유지
169
+ if (cached?.data && !cached.data.error) return cached.data;
170
+
171
+ // 실패도 캐시 (반복 호출 방지)
172
+ const errorResult = { error: errorMessage };
173
+ usageCache[cacheKey] = { data: errorResult, cachedAt: Date.now() };
174
+ return errorResult;
175
+ }
176
+
177
+ const data = (await res.json()) as UsageResponse;
178
+ // cache invalidate
179
+ usageCache[cacheKey] = { data, cachedAt: Date.now() };
180
+ return data;
181
+ }
@@ -0,0 +1,132 @@
1
+ /**
2
+ * ClaudePool — 멀티 토큰 프로세스 풀.
3
+ *
4
+ * 2-layer 아키텍처: ClaudePool → Worker (flat)
5
+ * - Map<token, Worker[]>로 토큰별 워커 관리 (원본 토큰이 키)
6
+ * - least-queue-depth 라우팅
7
+ * - 투명한 쿼터 failover (QuotaError 시 다른 토큰으로 자동 재시도)
8
+ * - 워커 관리만 담당 (DB 의존 없음)
9
+ */
10
+ import type { CliResult, PoolConfig, QueryInput, TokenStats } from "./qgrid.types";
11
+ import { QuotaError } from "./qgrid.types";
12
+ import { Worker, type WorkerConfig } from "./worker";
13
+
14
+ export class ClaudePool {
15
+ workers = new Map<string, Worker[]>();
16
+ quotaExhausted = new Set<string>();
17
+ requestCounts = new Map<string, number>();
18
+ lastUsedToken = "";
19
+ size: number;
20
+ model: string;
21
+ timeout: number;
22
+ cwd: string;
23
+ maxCalls: number;
24
+
25
+ constructor(config: PoolConfig) {
26
+ this.size = config.size ?? 3;
27
+ this.model = config.model ?? "sonnet";
28
+ this.timeout = config.timeout ?? 300_000;
29
+ this.cwd = config.cwd ?? "/tmp/qgrid";
30
+ this.maxCalls = config.maxCalls ?? 500;
31
+
32
+ config.tokens.forEach((token) => {
33
+ this.createWorkers(token);
34
+ });
35
+ }
36
+
37
+ getStats(tokenNames?: Map<string, string>): TokenStats[] {
38
+ return [...this.workers.keys()].map((token) => ({
39
+ token,
40
+ name: tokenNames?.get(token) ?? "Unknown Key",
41
+ requests: this.requestCounts.get(token) ?? 0,
42
+ active: !this.quotaExhausted.has(token),
43
+ }));
44
+ }
45
+
46
+ selectWorker(): Worker | null {
47
+ const candidates = [...this.workers.entries()]
48
+ .filter(([token]) => !this.quotaExhausted.has(token))
49
+ .flatMap(([, workers]) => workers);
50
+
51
+ if (candidates.length === 0) return null;
52
+
53
+ return candidates.reduce((best, w) => (w.getQueueDepth() < best.getQueueDepth() ? w : best));
54
+ }
55
+
56
+ async query(input: QueryInput, timeoutMs?: number): Promise<CliResult> {
57
+ const triedTokens = new Set<string>();
58
+
59
+ while (true) {
60
+ const worker = this.selectWorker();
61
+ if (!worker) {
62
+ throw new QuotaError("All tokens exhausted");
63
+ }
64
+
65
+ if (triedTokens.has(worker.tokenId)) {
66
+ throw new QuotaError("All tokens exhausted");
67
+ }
68
+
69
+ try {
70
+ const result = await worker.query(input, timeoutMs);
71
+ this.lastUsedToken = worker.tokenId;
72
+ this.requestCounts.set(worker.tokenId, (this.requestCounts.get(worker.tokenId) ?? 0) + 1);
73
+
74
+ return result;
75
+ } catch (err) {
76
+ if (err instanceof QuotaError) {
77
+ this.quotaExhausted.add(worker.tokenId);
78
+ triedTokens.add(worker.tokenId);
79
+ continue;
80
+ }
81
+ throw err;
82
+ }
83
+ }
84
+ }
85
+
86
+ kill(): void {
87
+ [...this.workers.values()].flat().forEach((w) => {
88
+ w.kill();
89
+ });
90
+ }
91
+
92
+ createWorkers(token: string): void {
93
+ if (this.workers.has(token)) return;
94
+
95
+ const workerConfig: WorkerConfig = {
96
+ token,
97
+ model: this.model,
98
+ timeout: this.timeout,
99
+ cwd: this.cwd,
100
+ maxCalls: this.maxCalls,
101
+ };
102
+
103
+ const workers = Array.from({ length: this.size }, () => new Worker(workerConfig));
104
+ this.workers.set(token, workers);
105
+ this.requestCounts.set(token, 0);
106
+ }
107
+
108
+ destroyWorkers(token: string): void {
109
+ const workers = this.workers.get(token);
110
+ if (!workers) return;
111
+
112
+ workers.forEach((w) => {
113
+ w.kill();
114
+ });
115
+ this.workers.delete(token);
116
+ this.quotaExhausted.delete(token);
117
+ this.requestCounts.delete(token);
118
+ }
119
+ }
120
+
121
+ // Pool 싱글턴 관리
122
+ let pool: ClaudePool | null = null;
123
+
124
+ export function initPool(tokens: string[]): ClaudePool {
125
+ pool = new ClaudePool({ tokens });
126
+ return pool;
127
+ }
128
+
129
+ export function getPool(): ClaudePool {
130
+ if (!pool) throw new Error("Pool not initialized. Call initPool() first.");
131
+ return pool;
132
+ }
@@ -0,0 +1,275 @@
1
+ import type { FastifyReply } from "fastify";
2
+ import { api, BaseFrameClass } from "sonamu";
3
+
4
+ import { RequestLogModel } from "../request-log/request-log.model";
5
+ import { TokenModel } from "../token/token.model";
6
+ import type { RefreshTokenParams } from "../token/token.types";
7
+ import {
8
+ buildAuthUrl,
9
+ exchangeCodeForTokens,
10
+ fetchUsage,
11
+ generatePKCE,
12
+ refreshAccessToken,
13
+ } from "./oauth";
14
+ import { type ClaudePool, getPool } from "./pool";
15
+ import type {
16
+ CliResult,
17
+ HealthResponse,
18
+ OAuthStartResult,
19
+ TokenStats,
20
+ UsageResponse,
21
+ } from "./qgrid.types";
22
+
23
+ // PKCE 세션 메모리 저장 (state → { codeVerifier, name, redirectUri })
24
+ const pendingOAuth = new Map<string, { codeVerifier: string; name: string; redirectUri: string }>();
25
+
26
+ class QgridFrameClass extends BaseFrameClass {
27
+ constructor() {
28
+ super("Qgrid");
29
+ }
30
+
31
+ @api({ httpMethod: "POST", clients: ["axios", "tanstack-mutation"] })
32
+ async query(prompt: string, system?: string, timeout?: number): Promise<CliResult> {
33
+ const pool = getPool();
34
+ const result = await pool.query({ system, prompt }, timeout);
35
+
36
+ // 로그 기록 실패해도 쿼리 결과는 반환
37
+ TokenModel.findByToken("A", pool.lastUsedToken)
38
+ .then((tokenEntry) => {
39
+ RequestLogModel.save([
40
+ {
41
+ token_name: tokenEntry?.name ?? "Unknown Key",
42
+ query: system ? `[System]\n${system}\n\n[User]\n${prompt}` : prompt,
43
+ response: result.text,
44
+ input_tokens: result.usage.input_tokens,
45
+ output_tokens: result.usage.output_tokens,
46
+ cache_read_tokens: result.usage.cache_read_input_tokens,
47
+ cache_creation_tokens: result.usage.cache_creation_input_tokens,
48
+ duration_ms: result.durationMs,
49
+ },
50
+ ]);
51
+ })
52
+ .catch((e) => console.error("requestLog save failed:", e));
53
+
54
+ return result;
55
+ }
56
+
57
+ @api({ httpMethod: "GET", clients: ["axios", "tanstack-query"] })
58
+ async stats(): Promise<TokenStats[]> {
59
+ const pool = getPool();
60
+ const entries = await TokenModel.findActive("A");
61
+ const tokenNames = new Map(entries.map((e) => [e.token, e.name]));
62
+ return pool.getStats(tokenNames);
63
+ }
64
+
65
+ @api({ httpMethod: "POST", clients: ["axios", "tanstack-mutation"] })
66
+ async addToken(token: string, name: string, refreshToken?: string): Promise<{ added: boolean }> {
67
+ await TokenModel.save([
68
+ {
69
+ token,
70
+ name,
71
+ ...(refreshToken && refreshToken.length > 0 ? { refresh_token: refreshToken } : {}),
72
+ },
73
+ ]);
74
+ getPool().createWorkers(token);
75
+ return { added: true };
76
+ }
77
+
78
+ @api({ httpMethod: "POST", clients: ["axios", "tanstack-mutation"] })
79
+ async updateToken(
80
+ token: string,
81
+ name?: string,
82
+ newToken?: string,
83
+ refreshToken?: string,
84
+ ): Promise<{ updated: boolean }> {
85
+ const pool = getPool();
86
+ const entry = await TokenModel.findByToken("A", token);
87
+ if (!entry) return { updated: false };
88
+
89
+ const hasNewToken = newToken !== undefined && newToken.length > 0;
90
+ const hasRefreshToken = refreshToken !== undefined && refreshToken.length > 0;
91
+ await TokenModel.save([
92
+ {
93
+ id: entry.id,
94
+ token: hasNewToken ? newToken : entry.token,
95
+ name: name !== undefined ? name : entry.name,
96
+ ...(hasRefreshToken ? { refresh_token: refreshToken } : {}),
97
+ },
98
+ ]);
99
+
100
+ if (hasNewToken) {
101
+ pool.destroyWorkers(token);
102
+ pool.createWorkers(newToken);
103
+ }
104
+ return { updated: true };
105
+ }
106
+
107
+ @api({ httpMethod: "POST", clients: ["axios", "tanstack-mutation"] })
108
+ async removeToken(token: string): Promise<{ removed: boolean }> {
109
+ const pool = getPool();
110
+ if (!pool.workers.has(token)) return { removed: false };
111
+
112
+ const entry = await TokenModel.findByToken("A", token);
113
+ if (entry) await TokenModel.del([entry.id]);
114
+ pool.destroyWorkers(token);
115
+ return { removed: true };
116
+ }
117
+
118
+ /**
119
+ *
120
+ * @param id token_id
121
+ * 토큰 활성화/비활성화 토글 (pool 워커 생성/제거 & DB의 active 필드 업데이트)
122
+ *
123
+ * @returns 토큰 활성화 여부
124
+ */
125
+ @api({ httpMethod: "POST", clients: ["axios", "tanstack-mutation"] })
126
+ async toggleToken(id: number): Promise<{ active: boolean }> {
127
+ const pool = getPool();
128
+ const entry = await TokenModel.findOne("A", { id });
129
+ if (!entry) return { active: false };
130
+
131
+ const newActive = !entry.active;
132
+ await TokenModel.save([{ id, token: entry.token, active: newActive, name: entry.name }]);
133
+
134
+ // 토큰의 새로운 상태가 활성화면/비활성화면
135
+ if (newActive) {
136
+ // 워커 생성
137
+ pool.createWorkers(entry.token);
138
+ } else {
139
+ // 워커 제거
140
+ pool.destroyWorkers(entry.token);
141
+ }
142
+
143
+ return { active: newActive };
144
+ }
145
+
146
+ // OAuth 로그인: authUrl 생성 (프론트에서 이 API 호출 후 authUrl로 리다이렉트)
147
+ @api({ httpMethod: "POST", clients: ["axios", "tanstack-mutation"] })
148
+ async oauthStart(name: string): Promise<OAuthStartResult> {
149
+ const { codeVerifier, codeChallenge, state } = generatePKCE();
150
+
151
+ const serverPort = process.env.PORT ?? "44900";
152
+ const redirectUri = `http://localhost:${serverPort}/callback`;
153
+ const authUrl = buildAuthUrl(codeChallenge, state, redirectUri);
154
+
155
+ // PKCE를 메모리에 저장 (5분 TTL)
156
+ pendingOAuth.set(state, { codeVerifier, name, redirectUri });
157
+ setTimeout(() => pendingOAuth.delete(state), 300_000);
158
+
159
+ return { authUrl };
160
+ }
161
+
162
+ // OAuth 콜백 처리 — sonamu.config.ts의 custom 라우트에서 호출됨
163
+ async handleOAuthCallback(code: string, state: string, reply: FastifyReply): Promise<void> {
164
+ const pool = getPool();
165
+ const pending = pendingOAuth.get(state);
166
+ if (!pending) {
167
+ return reply.redirect("/?oauth=error&reason=invalid_state");
168
+ }
169
+ pendingOAuth.delete(state);
170
+
171
+ try {
172
+ const tokens = await exchangeCodeForTokens(
173
+ code,
174
+ pending.codeVerifier,
175
+ state,
176
+ pending.redirectUri,
177
+ );
178
+
179
+ // 같은 계정의 이전 토큰이 있으면 교체
180
+ if (tokens.accountUuid) {
181
+ const oldEntries = await TokenModel.findByAccountUuid("A", tokens.accountUuid);
182
+ for (const old of oldEntries) {
183
+ pool.destroyWorkers(old.token);
184
+ await TokenModel.del([old.id]);
185
+ }
186
+ }
187
+
188
+ // 새 토큰 저장 + pool에 등록
189
+ await TokenModel.save([
190
+ {
191
+ token: tokens.accessToken,
192
+ name: pending.name,
193
+ refresh_token: tokens.refreshToken,
194
+ expires_at: tokens.expiresAt ? BigInt(tokens.expiresAt) : null,
195
+ account_uuid: tokens.accountUuid,
196
+ },
197
+ ]);
198
+ pool.createWorkers(tokens.accessToken);
199
+
200
+ return reply.redirect(`/?oauth=success&name=${encodeURIComponent(pending.name)}`);
201
+ } catch (e) {
202
+ return reply.redirect(`/?oauth=error&reason=${encodeURIComponent((e as Error).message)}`);
203
+ }
204
+ }
205
+
206
+ // 토큰 사용량 조회 (OAuth usage API)
207
+ @api({ httpMethod: "GET", clients: ["axios", "tanstack-query"] })
208
+ async usage(tokenName?: string): Promise<UsageResponse> {
209
+ const pool = getPool();
210
+ const { rows: allTokens } = await TokenModel.findMany("A");
211
+ const entry = tokenName
212
+ ? allTokens.find((e) => e.name === tokenName)
213
+ : allTokens.findLast((e) => e.active);
214
+
215
+ if (!entry) return { error: "NOT_FOUND" };
216
+
217
+ // 만료 확인 + refresh 시도
218
+ let accessToken = entry.token;
219
+ const isExpired = entry.expires_at && Number(entry.expires_at) < Date.now();
220
+
221
+ if (isExpired && entry.refresh_token) {
222
+ try {
223
+ accessToken = await this.refreshToken(pool, entry);
224
+ } catch (e) {
225
+ console.warn(`[usage] refresh failed for ${entry.name}: ${(e as Error).message}`);
226
+ return { error: "re-login required" };
227
+ }
228
+ }
229
+
230
+ // usage 호출
231
+ const result = await fetchUsage(accessToken);
232
+
233
+ // 인증 에러면 refresh_token으로 재시도
234
+ if (result.error && entry.refresh_token) {
235
+ try {
236
+ accessToken = await this.refreshToken(pool, entry);
237
+ return await fetchUsage(accessToken);
238
+ } catch (e) {
239
+ console.warn(`[usage] refresh failed for ${entry.name}: ${(e as Error).message}`);
240
+ return { error: "re-login required" };
241
+ }
242
+ }
243
+
244
+ return result;
245
+ }
246
+
247
+ async refreshToken(pool: ClaudePool, token: RefreshTokenParams): Promise<string> {
248
+ if (!token.refresh_token) throw new Error("No refresh token");
249
+ const refreshed = await refreshAccessToken(token.refresh_token);
250
+ await TokenModel.save([
251
+ {
252
+ id: token.id,
253
+ token: refreshed.accessToken,
254
+ refresh_token: refreshed.refreshToken,
255
+ expires_at: BigInt(refreshed.expiresAt),
256
+ name: token.name ?? "",
257
+ },
258
+ ]);
259
+ pool.destroyWorkers(token.token);
260
+ pool.createWorkers(refreshed.accessToken);
261
+ return refreshed.accessToken;
262
+ }
263
+
264
+ @api({ httpMethod: "GET", clients: ["axios", "tanstack-query"] })
265
+ async health(): Promise<HealthResponse> {
266
+ const pool = getPool();
267
+ return {
268
+ status: "ok",
269
+ workers: [...pool.workers.values()].flat().length,
270
+ activeTokens: pool.workers.size - pool.quotaExhausted.size,
271
+ };
272
+ }
273
+ }
274
+
275
+ export const QgridFrame = new QgridFrameClass();
@@ -0,0 +1,117 @@
1
+ import { z } from "zod";
2
+
3
+ // ─── Query ───
4
+
5
+ export const QueryInput = z.object({
6
+ system: z.string().optional(),
7
+ prompt: z.string(),
8
+ timeout: z.number().optional(),
9
+ });
10
+ export type QueryInput = z.infer<typeof QueryInput>;
11
+
12
+ export const CliResult = z.object({
13
+ text: z.string(),
14
+ usage: z.object({
15
+ input_tokens: z.number(),
16
+ output_tokens: z.number(),
17
+ cache_creation_input_tokens: z.number(),
18
+ cache_read_input_tokens: z.number(),
19
+ }),
20
+ durationMs: z.number(),
21
+ costUsd: z.number(),
22
+ });
23
+ export type CliResult = z.infer<typeof CliResult>;
24
+
25
+ // ─── Pool Config ───
26
+
27
+ export const PoolConfig = z.object({
28
+ tokens: z.array(z.string()),
29
+ size: z.number().optional(),
30
+ model: z.string().optional(),
31
+ timeout: z.number().optional(),
32
+ cwd: z.string().optional(),
33
+ maxCalls: z.number().optional(),
34
+ });
35
+ export type PoolConfig = z.infer<typeof PoolConfig>;
36
+
37
+ // ─── Token Management ───
38
+
39
+ export const AddTokenInput = z.object({
40
+ token: z.string(),
41
+ name: z.string(),
42
+ });
43
+ export type AddTokenInput = z.infer<typeof AddTokenInput>;
44
+
45
+ export const RemoveTokenInput = z.object({
46
+ token: z.string(),
47
+ });
48
+ export type RemoveTokenInput = z.infer<typeof RemoveTokenInput>;
49
+
50
+ export const TokenStats = z.object({
51
+ token: z.string(),
52
+ name: z.string(),
53
+ requests: z.number(),
54
+ active: z.boolean(),
55
+ });
56
+ export type TokenStats = z.infer<typeof TokenStats>;
57
+
58
+ // ─── OAuth ───
59
+
60
+ export const OAuthStartResult = z.object({
61
+ authUrl: z.string(),
62
+ });
63
+ export type OAuthStartResult = z.infer<typeof OAuthStartResult>;
64
+
65
+ const RateLimit = z
66
+ .object({
67
+ utilization: z.number().nullable(),
68
+ resets_at: z.string().nullable(),
69
+ })
70
+ .nullable();
71
+
72
+ export const UsageResponse = z.object({
73
+ error: z.string().optional(),
74
+ five_hour: RateLimit.optional(),
75
+ seven_day: RateLimit.optional(),
76
+ seven_day_opus: RateLimit.optional(),
77
+ seven_day_sonnet: RateLimit.optional(),
78
+ seven_day_oauth_apps: RateLimit.optional(),
79
+ seven_day_cowork: RateLimit.optional(),
80
+ extra_usage: z
81
+ .object({
82
+ is_enabled: z.boolean(),
83
+ monthly_limit: z.number().nullable(),
84
+ used_credits: z.number().nullable(),
85
+ utilization: z.number().nullable(),
86
+ })
87
+ .nullable()
88
+ .optional(),
89
+ });
90
+ export type UsageResponse = z.infer<typeof UsageResponse>;
91
+
92
+ // ─── Health ───
93
+
94
+ export const HealthResponse = z.object({
95
+ status: z.string(),
96
+ workers: z.number(),
97
+ activeTokens: z.number(),
98
+ });
99
+ export type HealthResponse = z.infer<typeof HealthResponse>;
100
+
101
+ // ─── Errors ───
102
+
103
+ export class QuotaError extends Error {
104
+ readonly code = "QUOTA_EXHAUSTED" as const;
105
+ }
106
+ export class TimeoutError extends Error {
107
+ readonly code = "TIMEOUT" as const;
108
+ }
109
+ export class ProcessError extends Error {
110
+ readonly code = "PROCESS_ERROR" as const;
111
+ }
112
+
113
+ // ─── Utils ───
114
+
115
+ export function maskToken(token: string): string {
116
+ return token.length > 4 ? `...${token.slice(-4)}` : token;
117
+ }