claude-codex-proxy 1.0.0

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.
Files changed (47) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +227 -0
  3. package/bin/cli.js +113 -0
  4. package/docs/ACCOUNTS.md +202 -0
  5. package/docs/API.md +244 -0
  6. package/docs/ARCHITECTURE.md +119 -0
  7. package/docs/CLAUDE_INTEGRATION.md +163 -0
  8. package/docs/OAUTH.md +83 -0
  9. package/docs/OPENCLAW.md +33 -0
  10. package/docs/legal.md +9 -0
  11. package/images/demo-screenshot.png +0 -0
  12. package/images/f757093f-507b-4453-994e-f8275f8b07a9.png +0 -0
  13. package/package.json +56 -0
  14. package/public/css/style.css +791 -0
  15. package/public/index.html +838 -0
  16. package/public/js/app.js +619 -0
  17. package/src/account-manager.js +526 -0
  18. package/src/account-rotation/index.js +93 -0
  19. package/src/account-rotation/rate-limits.js +293 -0
  20. package/src/account-rotation/strategies/base-strategy.js +48 -0
  21. package/src/account-rotation/strategies/index.js +31 -0
  22. package/src/account-rotation/strategies/round-robin-strategy.js +42 -0
  23. package/src/account-rotation/strategies/sticky-strategy.js +97 -0
  24. package/src/claude-config.js +154 -0
  25. package/src/cli/accounts.js +551 -0
  26. package/src/direct-api.js +214 -0
  27. package/src/format-converter.js +563 -0
  28. package/src/index.js +45 -0
  29. package/src/middleware/credentials.js +116 -0
  30. package/src/middleware/sse.js +130 -0
  31. package/src/model-api.js +189 -0
  32. package/src/model-mapper.js +88 -0
  33. package/src/oauth.js +623 -0
  34. package/src/response-streamer.js +469 -0
  35. package/src/routes/accounts-route.js +296 -0
  36. package/src/routes/api-routes.js +102 -0
  37. package/src/routes/chat-route.js +239 -0
  38. package/src/routes/claude-config-route.js +114 -0
  39. package/src/routes/logs-route.js +43 -0
  40. package/src/routes/messages-route.js +164 -0
  41. package/src/routes/models-route.js +115 -0
  42. package/src/routes/settings-route.js +154 -0
  43. package/src/server-settings.js +70 -0
  44. package/src/server.js +57 -0
  45. package/src/signature-cache.js +106 -0
  46. package/src/thinking-utils.js +312 -0
  47. package/src/utils/logger.js +170 -0
@@ -0,0 +1,296 @@
1
+ /**
2
+ * Accounts Route
3
+ * Handles all /accounts/* endpoints:
4
+ * GET /accounts
5
+ * GET /accounts/status
6
+ * GET /accounts/quota
7
+ * GET /accounts/quota/all
8
+ * POST /accounts/add
9
+ * POST /accounts/add/manual
10
+ * POST /accounts/switch
11
+ * POST /accounts/import
12
+ * POST /accounts/refresh
13
+ * POST /accounts/refresh/all
14
+ * POST /accounts/:email/refresh
15
+ * POST /accounts/oauth/cleanup
16
+ * DELETE /accounts/:email
17
+ */
18
+
19
+ import {
20
+ getActiveAccount,
21
+ setActiveAccount,
22
+ removeAccount,
23
+ listAccounts,
24
+ refreshActiveAccount,
25
+ refreshAccountToken,
26
+ refreshAllAccounts,
27
+ importFromCodex,
28
+ getStatus,
29
+ loadAccounts,
30
+ saveAccounts,
31
+ updateAccountAuth,
32
+ updateAccountQuota,
33
+ getAccountQuota
34
+ } from '../account-manager.js';
35
+
36
+ import {
37
+ getAuthorizationUrl,
38
+ generatePKCE,
39
+ generateState,
40
+ startCallbackServer,
41
+ exchangeCodeForTokens,
42
+ OAUTH_CONFIG,
43
+ extractCodeFromInput,
44
+ extractAccountInfo
45
+ } from '../oauth.js';
46
+
47
+ import {
48
+ getAccountQuota as fetchAccountQuota
49
+ } from '../model-api.js';
50
+
51
+ import { logger } from '../utils/logger.js';
52
+
53
+ // Tracks active OAuth callback servers keyed by port
54
+ const activeCallbackServers = new Map();
55
+
56
+ // ─── Route Handlers ──────────────────────────────────────────────────────────
57
+
58
+ export function handleListAccounts(req, res) {
59
+ res.json(listAccounts());
60
+ }
61
+
62
+ export function handleAccountStatus(req, res) {
63
+ res.json(getStatus());
64
+ }
65
+
66
+ export function handleOAuthCleanup(req, res) {
67
+ for (const [, server] of activeCallbackServers) {
68
+ try { server.close(); } catch { /* ignore */ }
69
+ }
70
+ activeCallbackServers.clear();
71
+ res.json({ success: true, message: 'OAuth servers cleaned up' });
72
+ }
73
+
74
+ export async function handleAddAccount(req, res) {
75
+ const { port } = req.body || {};
76
+ const callbackPort = port || OAUTH_CONFIG.callbackPort;
77
+
78
+ const { verifier } = generatePKCE();
79
+ const state = generateState();
80
+ const oauthUrl = getAuthorizationUrl(verifier, state, callbackPort);
81
+
82
+ // Close any existing server on this port
83
+ if (activeCallbackServers.has(callbackPort)) {
84
+ const existing = activeCallbackServers.get(callbackPort);
85
+ if (existing.abort) existing.abort();
86
+ activeCallbackServers.delete(callbackPort);
87
+ }
88
+
89
+ let serverResult;
90
+ try {
91
+ serverResult = startCallbackServer(state, 120000);
92
+ } catch (err) {
93
+ return res.status(500).json({
94
+ error: 'Failed to start OAuth callback server',
95
+ message: err.message,
96
+ status: 'error'
97
+ });
98
+ }
99
+
100
+ activeCallbackServers.set(callbackPort, serverResult);
101
+
102
+ serverResult.promise
103
+ .then(result => {
104
+ activeCallbackServers.delete(callbackPort);
105
+ if (result?.code) {
106
+ return exchangeCodeForTokens(result.code, verifier, callbackPort)
107
+ .then(async tokens => {
108
+ const accountInfo = extractAccountInfo(tokens);
109
+ await _upsertAccount(accountInfo);
110
+ logger.info(`Added account: ${accountInfo.email}`);
111
+ });
112
+ }
113
+ })
114
+ .catch(err => {
115
+ activeCallbackServers.delete(callbackPort);
116
+ logger.error(`OAuth token exchange failed: ${err.message}`);
117
+ });
118
+
119
+ res.json({
120
+ status: 'oauth_url',
121
+ oauth_url: oauthUrl,
122
+ verifier,
123
+ state,
124
+ callback_port: callbackPort
125
+ });
126
+ }
127
+
128
+ export async function handleAddAccountManual(req, res) {
129
+ const { code, verifier } = req.body || {};
130
+
131
+ if (!code) {
132
+ return res.status(400).json({ success: false, error: 'Code is required' });
133
+ }
134
+
135
+ try {
136
+ const { code: extractedCode } = extractCodeFromInput(code);
137
+ const tokens = await exchangeCodeForTokens(extractedCode, verifier);
138
+ const accountInfo = extractAccountInfo(tokens);
139
+
140
+ await _upsertAccount(accountInfo);
141
+ logger.info(`Added account via manual OAuth: ${accountInfo.email}`);
142
+ res.json({ success: true, message: `Account ${accountInfo.email} added successfully` });
143
+ } catch (err) {
144
+ logger.error(`Manual OAuth failed: ${err.message}`);
145
+ res.status(400).json({ success: false, error: err.message });
146
+ }
147
+ }
148
+
149
+ export function handleSwitchAccount(req, res) {
150
+ const { email } = req.body || {};
151
+ if (!email) {
152
+ return res.status(400).json({ success: false, message: 'Email is required' });
153
+ }
154
+ const result = setActiveAccount(email);
155
+ if (result.success) {
156
+ logger.info(`Switched to account: ${email}`);
157
+ }
158
+ res.json(result);
159
+ }
160
+
161
+ export async function handleRefreshAccount(req, res) {
162
+ const email = decodeURIComponent(req.params.email);
163
+ const result = await refreshAccountToken(email);
164
+ if (result.success) {
165
+ logger.info(`Refreshed token for: ${email}`);
166
+ }
167
+ res.json(result);
168
+ }
169
+
170
+ export async function handleRefreshAllAccounts(req, res) {
171
+ const result = await refreshAllAccounts();
172
+ res.json(result);
173
+ }
174
+
175
+ export async function handleRefreshActiveAccount(req, res) {
176
+ const result = await refreshActiveAccount();
177
+ res.json(result);
178
+ }
179
+
180
+ export function handleRemoveAccount(req, res) {
181
+ const email = decodeURIComponent(req.params.email);
182
+ const result = removeAccount(email);
183
+ if (result.success) {
184
+ logger.info(`Removed account: ${email}`);
185
+ }
186
+ res.json(result);
187
+ }
188
+
189
+ export function handleImportAccount(req, res) {
190
+ const result = importFromCodex();
191
+ res.json(result);
192
+ }
193
+
194
+ export async function handleGetQuota(req, res) {
195
+ const { email, refresh } = req.query;
196
+ const account = email
197
+ ? loadAccounts().accounts.find(a => a.email === email)
198
+ : getActiveAccount();
199
+
200
+ if (!account) {
201
+ return res.status(404).json({
202
+ success: false,
203
+ error: email ? `Account not found: ${email}` : 'No active account'
204
+ });
205
+ }
206
+
207
+ const cachedQuota = getAccountQuota(account.email);
208
+ const isStale = !cachedQuota ||
209
+ (Date.now() - new Date(cachedQuota.lastChecked).getTime() > 5 * 60 * 1000);
210
+
211
+ if (refresh === 'true' || isStale) {
212
+ try {
213
+ const quotaData = await fetchAccountQuota(account.accessToken, account.accountId);
214
+ updateAccountQuota(account.email, quotaData);
215
+ res.json({ success: true, email: account.email, quota: quotaData, cached: false });
216
+ } catch (error) {
217
+ logger.error(`Failed to fetch quota: ${error.message}`);
218
+ if (cachedQuota) {
219
+ res.json({
220
+ success: true,
221
+ email: account.email,
222
+ quota: cachedQuota,
223
+ cached: true,
224
+ warning: 'Using cached data due to fetch error'
225
+ });
226
+ } else {
227
+ res.status(500).json({ success: false, error: error.message });
228
+ }
229
+ }
230
+ } else {
231
+ res.json({ success: true, email: account.email, quota: cachedQuota, cached: true });
232
+ }
233
+ }
234
+
235
+ export async function handleGetAllQuotas(req, res) {
236
+ const { accounts: accountList } = listAccounts();
237
+ const results = [];
238
+
239
+ for (const account of accountList) {
240
+ try {
241
+ const quota = await getAccountQuota(account.email);
242
+ results.push({ email: account.email, quota: quota || null });
243
+ } catch {
244
+ results.push({ email: account.email, quota: null });
245
+ }
246
+ }
247
+
248
+ res.json({ accounts: results });
249
+ }
250
+
251
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
252
+
253
+ /**
254
+ * Inserts or updates an account in the persisted accounts store,
255
+ * and sets it as the active account.
256
+ * @param {object} accountInfo
257
+ */
258
+ async function _upsertAccount(accountInfo) {
259
+ const data = loadAccounts();
260
+ const existingIndex = data.accounts.findIndex(a => a.email === accountInfo.email);
261
+
262
+ if (existingIndex >= 0) {
263
+ data.accounts[existingIndex] = { ...data.accounts[existingIndex], ...accountInfo };
264
+ } else {
265
+ data.accounts.push(accountInfo);
266
+ }
267
+
268
+ data.activeAccount = accountInfo.email;
269
+ saveAccounts(data);
270
+ updateAccountAuth(accountInfo);
271
+
272
+ // Fetch initial quota immediately
273
+ try {
274
+ const quotaData = await fetchAccountQuota(accountInfo.accessToken, accountInfo.accountId);
275
+ updateAccountQuota(accountInfo.email, quotaData);
276
+ logger.info(`Initial quota fetched for: ${accountInfo.email}`);
277
+ } catch (err) {
278
+ logger.warn(`Failed to fetch initial quota for ${accountInfo.email}: ${err.message}`);
279
+ }
280
+ }
281
+
282
+ export default {
283
+ handleListAccounts,
284
+ handleAccountStatus,
285
+ handleOAuthCleanup,
286
+ handleAddAccount,
287
+ handleAddAccountManual,
288
+ handleSwitchAccount,
289
+ handleRefreshAccount,
290
+ handleRefreshAllAccounts,
291
+ handleRefreshActiveAccount,
292
+ handleRemoveAccount,
293
+ handleImportAccount,
294
+ handleGetQuota,
295
+ handleGetAllQuotas
296
+ };
@@ -0,0 +1,102 @@
1
+ /**
2
+ * API Routes
3
+ * Thin registration layer — wires all route modules to the Express app.
4
+ * Business logic lives in the individual route files under src/routes/.
5
+ */
6
+
7
+ import express from 'express';
8
+ import { join, dirname } from 'path';
9
+ import { fileURLToPath } from 'url';
10
+
11
+ import { getStatus, ACCOUNTS_FILE } from '../account-manager.js';
12
+
13
+ // Route handlers
14
+ import { handleMessages } from './messages-route.js';
15
+ import { handleChatCompletion, handleCountTokens } from './chat-route.js';
16
+ import { handleListModels, handleAccountModels, handleAccountUsage } from './models-route.js';
17
+ import {
18
+ handleGetAccountStrategy,
19
+ handleSetAccountStrategy,
20
+ handleGetModelMap,
21
+ handleSetModelMap,
22
+ handleGetRawProxyLogs,
23
+ handleSetRawProxyLogs
24
+ } from './settings-route.js';
25
+ import { handleGetLogs, handleStreamLogs } from './logs-route.js';
26
+ import { handleGetClaudeConfig, handleSetProxyMode, handleSetDirectMode, handleSetClaudeApiEndpoint } from './claude-config-route.js';
27
+ import {
28
+ handleListAccounts,
29
+ handleAccountStatus,
30
+ handleOAuthCleanup,
31
+ handleAddAccount,
32
+ handleAddAccountManual,
33
+ handleSwitchAccount,
34
+ handleRefreshAccount,
35
+ handleRefreshAllAccounts,
36
+ handleRefreshActiveAccount,
37
+ handleRemoveAccount,
38
+ handleImportAccount,
39
+ handleGetQuota,
40
+ handleGetAllQuotas
41
+ } from './accounts-route.js';
42
+
43
+ const __dirname = dirname(fileURLToPath(import.meta.url));
44
+
45
+ export function registerApiRoutes(app, { port }) {
46
+ // ─── Static Web UI ─────────────────────────────────────────────────────────
47
+ app.use(express.static(join(__dirname, '..', '..', 'public')));
48
+
49
+ // ─── Health ────────────────────────────────────────────────────────────────
50
+ app.get('/health', (req, res) => {
51
+ res.json({ status: 'ok', ...getStatus(), configPath: ACCOUNTS_FILE });
52
+ });
53
+
54
+ // ─── Anthropic Messages API ────────────────────────────────────────────────
55
+ app.post('/v1/messages', handleMessages);
56
+ app.post('/v1/messages/count_tokens', handleCountTokens);
57
+
58
+ // ─── OpenAI Chat Completions API ───────────────────────────────────────────
59
+ app.post('/v1/chat/completions', handleChatCompletion);
60
+
61
+ // ─── Models ────────────────────────────────────────────────────────────────
62
+ app.get('/v1/models', handleListModels);
63
+ app.get('/accounts/models', handleAccountModels);
64
+ app.get('/accounts/usage', handleAccountUsage);
65
+
66
+ // ─── Settings ──────────────────────────────────────────────────────────────
67
+ app.get('/settings/account-strategy', handleGetAccountStrategy);
68
+ app.post('/settings/account-strategy', handleSetAccountStrategy);
69
+ app.get('/settings/model-map', handleGetModelMap);
70
+ app.post('/settings/model-map', handleSetModelMap);
71
+ app.get('/settings/raw-proxy-logs', handleGetRawProxyLogs);
72
+ app.post('/settings/raw-proxy-logs', handleSetRawProxyLogs);
73
+
74
+ // ─── Account Management ───────────────────────────────────────────────────
75
+ app.get('/accounts', handleListAccounts);
76
+ app.get('/accounts/status', handleAccountStatus);
77
+ app.get('/accounts/quota', handleGetQuota);
78
+ app.get('/accounts/quota/all', handleGetAllQuotas);
79
+
80
+ app.post('/accounts/add', handleAddAccount);
81
+ app.post('/accounts/add/manual', handleAddAccountManual);
82
+ app.post('/accounts/switch', handleSwitchAccount);
83
+ app.post('/accounts/import', handleImportAccount);
84
+ app.post('/accounts/refresh', handleRefreshActiveAccount);
85
+ app.post('/accounts/refresh/all', handleRefreshAllAccounts);
86
+ app.post('/accounts/oauth/cleanup', handleOAuthCleanup);
87
+ app.post('/accounts/:email/refresh', handleRefreshAccount);
88
+
89
+ app.delete('/accounts/:email', handleRemoveAccount);
90
+
91
+ // ─── Claude CLI Configuration ──────────────────────────────────────────────
92
+ app.get('/claude/config', handleGetClaudeConfig);
93
+ app.post('/claude/config/proxy', (req, res) => handleSetProxyMode(req, res, { port }));
94
+ app.post('/claude/config/direct', handleSetDirectMode);
95
+ app.post('/claude/config/set', handleSetClaudeApiEndpoint);
96
+
97
+ // ─── Logs ──────────────────────────────────────────────────────────────────
98
+ app.get('/api/logs', handleGetLogs);
99
+ app.get('/api/logs/stream', handleStreamLogs);
100
+ }
101
+
102
+ export default { registerApiRoutes };
@@ -0,0 +1,239 @@
1
+ /**
2
+ * Chat Completions Route
3
+ * Handles POST /v1/chat/completions (OpenAI Chat Completions API compatibility)
4
+ * and POST /v1/messages/count_tokens (approximate token counting).
5
+ */
6
+
7
+ import { sendMessage } from '../direct-api.js';
8
+ import { resolveModelRouting } from '../model-mapper.js';
9
+ import { getCredentialsOrError, sendAuthError } from '../middleware/credentials.js';
10
+ import { handleStreamError } from '../middleware/sse.js';
11
+ import { logger } from '../utils/logger.js';
12
+ import { getServerSettings } from '../server-settings.js';
13
+
14
+ /**
15
+ * POST /v1/chat/completions
16
+ * Converts OpenAI Chat format to Anthropic internally, then routes to Codex.
17
+ * Always returns a non-streaming OpenAI-compatible response.
18
+ */
19
+ function getRequestContext(req) {
20
+ try {
21
+ const metadata = JSON.parse(req.body?.metadata?.user_id || '{}');
22
+ return { sessionId: metadata.session_id };
23
+ } catch (_) {
24
+ return { sessionId: undefined };
25
+ }
26
+ }
27
+
28
+ export async function handleChatCompletion(req, res) {
29
+ const startTime = Date.now();
30
+ const body = req.body;
31
+ const settings = getServerSettings();
32
+ if (settings.logRawProxyPayloads === true) {
33
+ logger.info('[Client Request]', body);
34
+ }
35
+ const requestedModel = body.model || 'gpt-5.2';
36
+
37
+ const { upstreamModel } = resolveModelRouting(requestedModel);
38
+
39
+ const creds = await getCredentialsOrError();
40
+ if (!creds) {
41
+ logger.response(401, { error: 'No active account' });
42
+ return sendAuthError(res, 'No active account. Add an account via /accounts/add');
43
+ }
44
+
45
+ const anthropicRequest = _buildAnthropicRequest(body, upstreamModel);
46
+
47
+ logger.request('POST', '/v1/chat/completions', {
48
+ model: upstreamModel,
49
+ account: creds.email,
50
+ messages: body.messages?.length || 0,
51
+ tools: body.tools?.length || 0
52
+ });
53
+
54
+ try {
55
+ const response = await sendMessage(anthropicRequest, creds.accessToken, creds.accountId, getRequestContext(req));
56
+
57
+ const duration = Date.now() - startTime;
58
+ logger.response(200, { model: upstreamModel, ...getTokenDetails(response.usage), duration });
59
+
60
+ res.json(_buildOpenAIResponse(response, requestedModel));
61
+ } catch (error) {
62
+ handleStreamError(res, error, upstreamModel, startTime);
63
+ }
64
+ }
65
+
66
+ /**
67
+ * POST /v1/messages/count_tokens
68
+ * Returns an approximate token count for the given request body.
69
+ */
70
+ export function handleCountTokens(req, res) {
71
+ const body = req.body;
72
+ let text = '';
73
+
74
+ if (body.system) {
75
+ if (typeof body.system === 'string') {
76
+ text += body.system + ' ';
77
+ } else if (Array.isArray(body.system)) {
78
+ for (const block of body.system) {
79
+ if (block.type === 'text') text += block.text + ' ';
80
+ }
81
+ }
82
+ }
83
+
84
+ if (body.tools) {
85
+ for (const tool of body.tools) {
86
+ text += JSON.stringify(tool) + ' ';
87
+ }
88
+ }
89
+
90
+ if (Array.isArray(body.messages)) {
91
+ for (const msg of body.messages) {
92
+ if (typeof msg.content === 'string') {
93
+ text += msg.content + ' ';
94
+ } else if (Array.isArray(msg.content)) {
95
+ for (const block of msg.content) {
96
+ if (block.type === 'text') {
97
+ text += block.text + ' ';
98
+ } else if (block.type === 'tool_use' || block.type === 'tool_result') {
99
+ text += JSON.stringify(block) + ' ';
100
+ }
101
+ }
102
+ }
103
+ }
104
+ }
105
+
106
+ const approxTokens = Math.ceil(text.length / 4);
107
+ res.json({ input_tokens: approxTokens });
108
+ }
109
+
110
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
111
+
112
+ /**
113
+ * Converts an OpenAI Chat Completions request body into an Anthropic-style request.
114
+ * @param {object} body
115
+ * @param {string} upstreamModel
116
+ * @returns {object}
117
+ */
118
+ function _buildAnthropicRequest(body, upstreamModel) {
119
+ const anthropicRequest = {
120
+ model: upstreamModel,
121
+ messages: [],
122
+ system: null,
123
+ stream: false
124
+ };
125
+
126
+ if (body.messages) {
127
+ const systemMsg = body.messages.find(m => m.role === 'system');
128
+ if (systemMsg) {
129
+ anthropicRequest.system = systemMsg.content;
130
+ }
131
+
132
+ anthropicRequest.messages = body.messages
133
+ .filter(m => m.role !== 'system')
134
+ .map(m => {
135
+ if (m.role === 'tool') {
136
+ return {
137
+ role: 'user',
138
+ content: [{
139
+ type: 'tool_result',
140
+ tool_use_id: m.tool_call_id,
141
+ content: m.content
142
+ }]
143
+ };
144
+ }
145
+
146
+ if (m.role === 'assistant' && m.tool_calls) {
147
+ const content = [{ type: 'text', text: m.content || '' }];
148
+ for (const call of m.tool_calls) {
149
+ let input = {};
150
+ try {
151
+ input = typeof call.function.arguments === 'string'
152
+ ? JSON.parse(call.function.arguments)
153
+ : call.function.arguments || {};
154
+ } catch {
155
+ input = {};
156
+ }
157
+ content.push({
158
+ type: 'tool_use',
159
+ id: call.id,
160
+ name: call.function.name,
161
+ input
162
+ });
163
+ }
164
+ return { role: 'assistant', content };
165
+ }
166
+
167
+ return m;
168
+ });
169
+ }
170
+
171
+ if (body.tools) {
172
+ anthropicRequest.tools = body.tools.map(t => ({
173
+ name: t.function.name,
174
+ description: t.function.description,
175
+ input_schema: t.function.parameters
176
+ }));
177
+ }
178
+
179
+ return anthropicRequest;
180
+ }
181
+
182
+ /**
183
+ * Converts an Anthropic-style response into an OpenAI Chat Completions response.
184
+ * @param {object} response
185
+ * @param {string} responseModel
186
+ * @returns {object}
187
+ */
188
+ function _buildOpenAIResponse(response, responseModel) {
189
+ const content = response.content || [];
190
+ const textContent = content.find(c => c.type === 'text');
191
+ const toolUses = content.filter(c => c.type === 'tool_use');
192
+
193
+ const message = {
194
+ role: 'assistant',
195
+ content: textContent?.text || ''
196
+ };
197
+
198
+ if (toolUses.length > 0) {
199
+ message.tool_calls = toolUses.map(t => ({
200
+ id: t.id,
201
+ type: 'function',
202
+ function: {
203
+ name: t.name,
204
+ arguments: JSON.stringify(t.input)
205
+ }
206
+ }));
207
+ }
208
+
209
+ return {
210
+ id: response.id,
211
+ object: 'chat.completion',
212
+ created: Math.floor(Date.now() / 1000),
213
+ model: responseModel,
214
+ choices: [{
215
+ index: 0,
216
+ message,
217
+ finish_reason: toolUses.length > 0 ? 'tool_calls' : 'stop'
218
+ }],
219
+ usage: {
220
+ prompt_tokens: response.usage?.input_tokens || 0,
221
+ completion_tokens: response.usage?.output_tokens || 0,
222
+ total_tokens: (response.usage?.input_tokens || 0) + (response.usage?.output_tokens || 0)
223
+ }
224
+ };
225
+ }
226
+
227
+ function getTokenDetails(usage = {}) {
228
+ const inputTokens = usage?.input_tokens || 0;
229
+ const outputTokens = usage?.output_tokens || 0;
230
+ const cacheReadInputTokens = usage?.cache_read_input_tokens || 0;
231
+ return {
232
+ tokens: inputTokens + outputTokens,
233
+ inputTokens,
234
+ outputTokens,
235
+ cacheReadInputTokens
236
+ };
237
+ }
238
+
239
+ export default { handleChatCompletion, handleCountTokens };