@pikoloo/codex-proxy 1.0.6

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