llmapi-v2 2.1.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 (162) hide show
  1. package/.env.example +40 -0
  2. package/Dockerfile +17 -0
  3. package/dist/config.d.ts +48 -0
  4. package/dist/config.js +98 -0
  5. package/dist/config.js.map +1 -0
  6. package/dist/converter/request.d.ts +6 -0
  7. package/dist/converter/request.js +184 -0
  8. package/dist/converter/request.js.map +1 -0
  9. package/dist/converter/response.d.ts +6 -0
  10. package/dist/converter/response.js +76 -0
  11. package/dist/converter/response.js.map +1 -0
  12. package/dist/converter/stream.d.ts +54 -0
  13. package/dist/converter/stream.js +318 -0
  14. package/dist/converter/stream.js.map +1 -0
  15. package/dist/converter/types.d.ts +239 -0
  16. package/dist/converter/types.js +6 -0
  17. package/dist/converter/types.js.map +1 -0
  18. package/dist/data/posts.d.ts +19 -0
  19. package/dist/data/posts.js +462 -0
  20. package/dist/data/posts.js.map +1 -0
  21. package/dist/index.d.ts +1 -0
  22. package/dist/index.js +233 -0
  23. package/dist/index.js.map +1 -0
  24. package/dist/middleware/api-key-auth.d.ts +6 -0
  25. package/dist/middleware/api-key-auth.js +76 -0
  26. package/dist/middleware/api-key-auth.js.map +1 -0
  27. package/dist/middleware/quota-guard.d.ts +10 -0
  28. package/dist/middleware/quota-guard.js +27 -0
  29. package/dist/middleware/quota-guard.js.map +1 -0
  30. package/dist/middleware/rate-limiter.d.ts +5 -0
  31. package/dist/middleware/rate-limiter.js +50 -0
  32. package/dist/middleware/rate-limiter.js.map +1 -0
  33. package/dist/middleware/request-logger.d.ts +6 -0
  34. package/dist/middleware/request-logger.js +37 -0
  35. package/dist/middleware/request-logger.js.map +1 -0
  36. package/dist/middleware/session-auth.d.ts +19 -0
  37. package/dist/middleware/session-auth.js +99 -0
  38. package/dist/middleware/session-auth.js.map +1 -0
  39. package/dist/providers/aliyun.d.ts +13 -0
  40. package/dist/providers/aliyun.js +20 -0
  41. package/dist/providers/aliyun.js.map +1 -0
  42. package/dist/providers/base-provider.d.ts +36 -0
  43. package/dist/providers/base-provider.js +133 -0
  44. package/dist/providers/base-provider.js.map +1 -0
  45. package/dist/providers/deepseek.d.ts +11 -0
  46. package/dist/providers/deepseek.js +18 -0
  47. package/dist/providers/deepseek.js.map +1 -0
  48. package/dist/providers/registry.d.ts +18 -0
  49. package/dist/providers/registry.js +98 -0
  50. package/dist/providers/registry.js.map +1 -0
  51. package/dist/providers/types.d.ts +17 -0
  52. package/dist/providers/types.js +3 -0
  53. package/dist/providers/types.js.map +1 -0
  54. package/dist/routes/admin.d.ts +1 -0
  55. package/dist/routes/admin.js +153 -0
  56. package/dist/routes/admin.js.map +1 -0
  57. package/dist/routes/auth.d.ts +2 -0
  58. package/dist/routes/auth.js +318 -0
  59. package/dist/routes/auth.js.map +1 -0
  60. package/dist/routes/blog.d.ts +1 -0
  61. package/dist/routes/blog.js +29 -0
  62. package/dist/routes/blog.js.map +1 -0
  63. package/dist/routes/dashboard.d.ts +1 -0
  64. package/dist/routes/dashboard.js +184 -0
  65. package/dist/routes/dashboard.js.map +1 -0
  66. package/dist/routes/messages.d.ts +1 -0
  67. package/dist/routes/messages.js +309 -0
  68. package/dist/routes/messages.js.map +1 -0
  69. package/dist/routes/models.d.ts +1 -0
  70. package/dist/routes/models.js +39 -0
  71. package/dist/routes/models.js.map +1 -0
  72. package/dist/routes/payment.d.ts +1 -0
  73. package/dist/routes/payment.js +150 -0
  74. package/dist/routes/payment.js.map +1 -0
  75. package/dist/routes/sitemap.d.ts +1 -0
  76. package/dist/routes/sitemap.js +38 -0
  77. package/dist/routes/sitemap.js.map +1 -0
  78. package/dist/services/alipay.d.ts +27 -0
  79. package/dist/services/alipay.js +106 -0
  80. package/dist/services/alipay.js.map +1 -0
  81. package/dist/services/database.d.ts +4 -0
  82. package/dist/services/database.js +170 -0
  83. package/dist/services/database.js.map +1 -0
  84. package/dist/services/health-checker.d.ts +13 -0
  85. package/dist/services/health-checker.js +95 -0
  86. package/dist/services/health-checker.js.map +1 -0
  87. package/dist/services/mailer.d.ts +3 -0
  88. package/dist/services/mailer.js +91 -0
  89. package/dist/services/mailer.js.map +1 -0
  90. package/dist/services/metrics.d.ts +56 -0
  91. package/dist/services/metrics.js +94 -0
  92. package/dist/services/metrics.js.map +1 -0
  93. package/dist/services/remote-control.d.ts +20 -0
  94. package/dist/services/remote-control.js +209 -0
  95. package/dist/services/remote-control.js.map +1 -0
  96. package/dist/services/remote-ws.d.ts +5 -0
  97. package/dist/services/remote-ws.js +143 -0
  98. package/dist/services/remote-ws.js.map +1 -0
  99. package/dist/services/usage.d.ts +13 -0
  100. package/dist/services/usage.js +39 -0
  101. package/dist/services/usage.js.map +1 -0
  102. package/dist/utils/errors.d.ts +27 -0
  103. package/dist/utils/errors.js +48 -0
  104. package/dist/utils/errors.js.map +1 -0
  105. package/dist/utils/logger.d.ts +2 -0
  106. package/dist/utils/logger.js +14 -0
  107. package/dist/utils/logger.js.map +1 -0
  108. package/docker-compose.yml +19 -0
  109. package/package.json +39 -0
  110. package/public/robots.txt +8 -0
  111. package/src/config.ts +140 -0
  112. package/src/converter/request.ts +207 -0
  113. package/src/converter/response.ts +85 -0
  114. package/src/converter/stream.ts +373 -0
  115. package/src/converter/types.ts +257 -0
  116. package/src/data/posts.ts +474 -0
  117. package/src/index.ts +219 -0
  118. package/src/middleware/api-key-auth.ts +82 -0
  119. package/src/middleware/quota-guard.ts +28 -0
  120. package/src/middleware/rate-limiter.ts +61 -0
  121. package/src/middleware/request-logger.ts +36 -0
  122. package/src/middleware/session-auth.ts +91 -0
  123. package/src/providers/aliyun.ts +16 -0
  124. package/src/providers/base-provider.ts +148 -0
  125. package/src/providers/deepseek.ts +14 -0
  126. package/src/providers/registry.ts +111 -0
  127. package/src/providers/types.ts +26 -0
  128. package/src/routes/admin.ts +169 -0
  129. package/src/routes/auth.ts +369 -0
  130. package/src/routes/blog.ts +28 -0
  131. package/src/routes/dashboard.ts +208 -0
  132. package/src/routes/messages.ts +346 -0
  133. package/src/routes/models.ts +37 -0
  134. package/src/routes/payment.ts +189 -0
  135. package/src/routes/sitemap.ts +40 -0
  136. package/src/services/alipay.ts +116 -0
  137. package/src/services/database.ts +187 -0
  138. package/src/services/health-checker.ts +115 -0
  139. package/src/services/mailer.ts +90 -0
  140. package/src/services/metrics.ts +104 -0
  141. package/src/services/remote-control.ts +226 -0
  142. package/src/services/remote-ws.ts +145 -0
  143. package/src/services/usage.ts +57 -0
  144. package/src/types/express.d.ts +46 -0
  145. package/src/utils/errors.ts +44 -0
  146. package/src/utils/logger.ts +8 -0
  147. package/tsconfig.json +17 -0
  148. package/views/pages/404.ejs +14 -0
  149. package/views/pages/admin.ejs +307 -0
  150. package/views/pages/blog-post.ejs +378 -0
  151. package/views/pages/blog.ejs +148 -0
  152. package/views/pages/dashboard.ejs +441 -0
  153. package/views/pages/docs.ejs +807 -0
  154. package/views/pages/index.ejs +416 -0
  155. package/views/pages/login.ejs +170 -0
  156. package/views/pages/orders.ejs +111 -0
  157. package/views/pages/pricing.ejs +379 -0
  158. package/views/pages/register.ejs +397 -0
  159. package/views/pages/remote.ejs +334 -0
  160. package/views/pages/settings.ejs +373 -0
  161. package/views/partials/header.ejs +70 -0
  162. package/views/partials/nav.ejs +140 -0
@@ -0,0 +1,8 @@
1
+ User-agent: *
2
+ Allow: /
3
+ Disallow: /api/
4
+ Disallow: /admin
5
+ Disallow: /dashboard
6
+ Disallow: /settings
7
+
8
+ Sitemap: https://llmapi.pro/sitemap.xml
package/src/config.ts ADDED
@@ -0,0 +1,140 @@
1
+ export interface ProviderConfig {
2
+ name: string;
3
+ /** Native Anthropic-compatible endpoint (preferred) */
4
+ anthropicBaseUrl: string;
5
+ /** OpenAI-compatible endpoint (fallback for providers without native Anthropic support) */
6
+ openaiBaseUrl?: string;
7
+ apiKey: string;
8
+ enabled: boolean;
9
+ timeout: number;
10
+ /** Maps Claude model names to this provider's model names */
11
+ models: Record<string, string>;
12
+ /** Cost per 1M tokens in RMB: { input, output, cached_input } */
13
+ pricing: Record<string, { input: number; output: number; cached_input?: number }>;
14
+ }
15
+
16
+ export interface ModelRoute {
17
+ provider: string;
18
+ model: string;
19
+ priority: number;
20
+ }
21
+
22
+ /** Smart routing: which models to use based on task complexity */
23
+ export interface SmartRoutingConfig {
24
+ /** Model for heavy coding tasks (tools present, complex) */
25
+ codingModel: { provider: string; model: string };
26
+ /** Model for simple/chat tasks (no tools or trivial) */
27
+ lightModel: { provider: string; model: string };
28
+ /** Threshold: if message count < this AND no tools, use light model */
29
+ lightTaskMaxMessages: number;
30
+ }
31
+
32
+ export interface AppConfig {
33
+ port: number;
34
+ jwtSecret: string;
35
+ databaseUrl: string;
36
+ providers: ProviderConfig[];
37
+ modelRouting: Record<string, ModelRoute[]>;
38
+ smartRouting: SmartRoutingConfig;
39
+ siteUrl: string;
40
+ }
41
+
42
+ function env(key: string, fallback?: string): string {
43
+ const val = process.env[key];
44
+ if (val !== undefined) return val;
45
+ if (fallback !== undefined) return fallback;
46
+ throw new Error(`Missing required env var: ${key}`);
47
+ }
48
+
49
+ function envBool(key: string, fallback: boolean): boolean {
50
+ const val = process.env[key];
51
+ if (val === undefined) return fallback;
52
+ return val === 'true' || val === '1';
53
+ }
54
+
55
+ function envInt(key: string, fallback: number): number {
56
+ const val = process.env[key];
57
+ if (val === undefined) return fallback;
58
+ const n = parseInt(val, 10);
59
+ return isNaN(n) ? fallback : n;
60
+ }
61
+
62
+ export function loadConfig(): AppConfig {
63
+ const providers: ProviderConfig[] = [];
64
+
65
+ // Alibaba Bailian (DashScope) - PRIMARY: native Anthropic endpoint (China domestic)
66
+ if (envBool('ALIYUN_ENABLED', false)) {
67
+ providers.push({
68
+ name: 'aliyun',
69
+ anthropicBaseUrl: env('ALIYUN_ANTHROPIC_URL', 'https://dashscope.aliyuncs.com/apps/anthropic'),
70
+ openaiBaseUrl: env('ALIYUN_OPENAI_URL', 'https://dashscope.aliyuncs.com/compatible-mode/v1'),
71
+ apiKey: env('ALIYUN_API_KEY', ''),
72
+ enabled: true,
73
+ timeout: 300_000,
74
+ models: {
75
+ 'claude-sonnet-4-6': 'qwen3-coder-plus',
76
+ 'claude-opus-4-6': 'qwen-max',
77
+ 'claude-haiku-4-5': 'qwen-plus',
78
+ },
79
+ pricing: {
80
+ 'qwen3-coder-plus': { input: 3.5, output: 7.0, cached_input: 0.35 },
81
+ 'qwen-max': { input: 20.0, output: 60.0, cached_input: 2.0 },
82
+ 'qwen-plus': { input: 0.5, output: 1.5, cached_input: 0.05 },
83
+ },
84
+ });
85
+ }
86
+
87
+ // DeepSeek - native Anthropic endpoint for simple tasks
88
+ if (envBool('DEEPSEEK_ENABLED', false)) {
89
+ providers.push({
90
+ name: 'deepseek',
91
+ anthropicBaseUrl: env('DEEPSEEK_ANTHROPIC_URL', 'https://api.deepseek.com/anthropic'),
92
+ openaiBaseUrl: env('DEEPSEEK_OPENAI_URL', 'https://api.deepseek.com/v1'),
93
+ apiKey: env('DEEPSEEK_API_KEY', ''),
94
+ enabled: true,
95
+ timeout: 300_000,
96
+ models: {
97
+ 'claude-sonnet-4-6': 'deepseek-chat',
98
+ 'claude-opus-4-6': 'deepseek-chat',
99
+ 'claude-haiku-4-5': 'deepseek-chat',
100
+ },
101
+ pricing: {
102
+ 'deepseek-chat': { input: 1.0, output: 2.0, cached_input: 0.1 },
103
+ },
104
+ });
105
+ }
106
+
107
+ // Build model routing
108
+ const modelRouting: Record<string, ModelRoute[]> = {};
109
+ const claudeModels = ['claude-sonnet-4-6', 'claude-opus-4-6', 'claude-haiku-4-5'];
110
+
111
+ for (const claudeModel of claudeModels) {
112
+ const routes: ModelRoute[] = [];
113
+ for (let i = 0; i < providers.length; i++) {
114
+ const p = providers[i];
115
+ if (p.models[claudeModel]) {
116
+ routes.push({ provider: p.name, model: p.models[claudeModel], priority: i });
117
+ }
118
+ }
119
+ if (routes.length > 0) {
120
+ modelRouting[claudeModel] = routes.sort((a, b) => a.priority - b.priority);
121
+ }
122
+ }
123
+
124
+ // Smart routing config
125
+ const smartRouting: SmartRoutingConfig = {
126
+ codingModel: { provider: 'aliyun', model: 'qwen3-coder-plus' },
127
+ lightModel: { provider: 'deepseek', model: 'deepseek-chat' },
128
+ lightTaskMaxMessages: envInt('SMART_ROUTING_LIGHT_MAX_MESSAGES', 4),
129
+ };
130
+
131
+ return {
132
+ port: envInt('PORT', 3000),
133
+ jwtSecret: env('JWT_SECRET', 'dev-secret-change-me'),
134
+ databaseUrl: env('DATABASE_URL', 'postgresql://llmapi_user:change-this@postgres:5432/llmapi'),
135
+ providers,
136
+ modelRouting,
137
+ smartRouting,
138
+ siteUrl: env('SITE_URL', 'http://localhost:3000'),
139
+ };
140
+ }
@@ -0,0 +1,207 @@
1
+ import type {
2
+ AnthropicRequest,
3
+ AnthropicMessage,
4
+ AnthropicContentBlock,
5
+ AnthropicToolDefinition,
6
+ AnthropicToolChoice,
7
+ OpenAIRequest,
8
+ OpenAIMessage,
9
+ OpenAITool,
10
+ OpenAIToolCall,
11
+ OpenAIContentPart,
12
+ } from './types';
13
+
14
+ /**
15
+ * Convert an Anthropic Messages API request to OpenAI Chat Completions format.
16
+ * This is the inbound conversion: Claude Code -> our proxy -> provider.
17
+ */
18
+ export function convertRequest(req: AnthropicRequest): OpenAIRequest {
19
+ const openaiMessages = convertMessages(req.messages, req.system);
20
+ const openaiTools = convertTools(req.tools);
21
+ const openaiToolChoice = convertToolChoice(req.tool_choice);
22
+
23
+ const body: OpenAIRequest = {
24
+ model: '', // Will be set by the router
25
+ messages: openaiMessages,
26
+ stream: !!req.stream,
27
+ };
28
+
29
+ if (req.max_tokens) body.max_tokens = req.max_tokens;
30
+ if (req.temperature !== undefined) body.temperature = req.temperature;
31
+ if (req.top_p !== undefined) body.top_p = req.top_p;
32
+ if (req.stop_sequences) body.stop = req.stop_sequences;
33
+ if (openaiTools) body.tools = openaiTools;
34
+ if (openaiToolChoice) body.tool_choice = openaiToolChoice;
35
+ if (req.stream) body.stream_options = { include_usage: true };
36
+
37
+ return body;
38
+ }
39
+
40
+ /**
41
+ * Convert Anthropic messages array + system prompt to OpenAI messages.
42
+ */
43
+ function convertMessages(
44
+ messages: AnthropicMessage[],
45
+ system?: string | { type: string; text: string }[],
46
+ ): OpenAIMessage[] {
47
+ const result: OpenAIMessage[] = [];
48
+
49
+ // System prompt
50
+ if (system) {
51
+ const systemText = typeof system === 'string'
52
+ ? system
53
+ : system.map(b => b.text).join('\n');
54
+ if (systemText) {
55
+ result.push({ role: 'system', content: systemText });
56
+ }
57
+ }
58
+
59
+ for (const msg of messages) {
60
+ if (msg.role === 'user') {
61
+ convertUserMessage(msg, result);
62
+ } else if (msg.role === 'assistant') {
63
+ convertAssistantMessage(msg, result);
64
+ }
65
+ }
66
+
67
+ return result;
68
+ }
69
+
70
+ /**
71
+ * Convert a user message. Anthropic user messages can contain interleaved
72
+ * text and tool_result blocks. OpenAI requires tool results as separate messages.
73
+ */
74
+ function convertUserMessage(msg: AnthropicMessage, result: OpenAIMessage[]): void {
75
+ if (typeof msg.content === 'string') {
76
+ result.push({ role: 'user', content: msg.content });
77
+ return;
78
+ }
79
+
80
+ const textParts: string[] = [];
81
+ const imageParts: OpenAIContentPart[] = [];
82
+
83
+ for (const block of msg.content) {
84
+ switch (block.type) {
85
+ case 'text':
86
+ textParts.push(block.text);
87
+ break;
88
+
89
+ case 'tool_result': {
90
+ // Each tool_result becomes a separate OpenAI tool message
91
+ let content: string;
92
+ if (typeof block.content === 'string') {
93
+ content = block.content;
94
+ } else if (Array.isArray(block.content)) {
95
+ content = block.content
96
+ .map(b => ('text' in b ? b.text : JSON.stringify(b)))
97
+ .join('\n');
98
+ } else {
99
+ content = '';
100
+ }
101
+ if (block.is_error) {
102
+ content = `[ERROR] ${content}`;
103
+ }
104
+ result.push({
105
+ role: 'tool',
106
+ tool_call_id: block.tool_use_id,
107
+ content,
108
+ });
109
+ break;
110
+ }
111
+
112
+ case 'image': {
113
+ // Convert base64 image to OpenAI image_url format
114
+ const dataUrl = `data:${block.source.media_type};base64,${block.source.data}`;
115
+ imageParts.push({ type: 'image_url', image_url: { url: dataUrl } });
116
+ break;
117
+ }
118
+ }
119
+ }
120
+
121
+ // Emit text/image as user message
122
+ if (textParts.length > 0 && imageParts.length === 0) {
123
+ result.push({ role: 'user', content: textParts.join('\n') });
124
+ } else if (imageParts.length > 0) {
125
+ const parts: OpenAIContentPart[] = [];
126
+ if (textParts.length > 0) {
127
+ parts.push({ type: 'text', text: textParts.join('\n') });
128
+ }
129
+ parts.push(...imageParts);
130
+ result.push({ role: 'user', content: parts });
131
+ }
132
+ }
133
+
134
+ /**
135
+ * Convert an assistant message. Anthropic assistant messages can contain
136
+ * text, thinking, and tool_use blocks. OpenAI uses tool_calls array.
137
+ */
138
+ function convertAssistantMessage(msg: AnthropicMessage, result: OpenAIMessage[]): void {
139
+ if (typeof msg.content === 'string') {
140
+ result.push({ role: 'assistant', content: msg.content });
141
+ return;
142
+ }
143
+
144
+ const textParts: string[] = [];
145
+ const toolCalls: OpenAIToolCall[] = [];
146
+
147
+ for (const block of msg.content) {
148
+ switch (block.type) {
149
+ case 'text':
150
+ textParts.push(block.text);
151
+ break;
152
+
153
+ case 'thinking':
154
+ // Thinking blocks: some providers support reasoning_content,
155
+ // but for history replay we skip them (provider generates its own thinking)
156
+ break;
157
+
158
+ case 'tool_use':
159
+ toolCalls.push({
160
+ id: block.id,
161
+ type: 'function',
162
+ function: {
163
+ name: block.name,
164
+ arguments: JSON.stringify(block.input),
165
+ },
166
+ });
167
+ break;
168
+ }
169
+ }
170
+
171
+ const assistantMsg: any = { role: 'assistant' };
172
+ assistantMsg.content = textParts.length > 0 ? textParts.join('\n') : null;
173
+ if (toolCalls.length > 0) assistantMsg.tool_calls = toolCalls;
174
+
175
+ result.push(assistantMsg as OpenAIMessage);
176
+ }
177
+
178
+ /**
179
+ * Convert Anthropic tool definitions to OpenAI format.
180
+ */
181
+ function convertTools(tools?: AnthropicToolDefinition[]): OpenAITool[] | undefined {
182
+ if (!tools || tools.length === 0) return undefined;
183
+ return tools.map(tool => ({
184
+ type: 'function' as const,
185
+ function: {
186
+ name: tool.name,
187
+ description: tool.description || '',
188
+ parameters: tool.input_schema || {},
189
+ },
190
+ }));
191
+ }
192
+
193
+ /**
194
+ * Convert Anthropic tool_choice to OpenAI format.
195
+ */
196
+ function convertToolChoice(
197
+ choice?: AnthropicToolChoice,
198
+ ): string | { type: 'function'; function: { name: string } } | undefined {
199
+ if (!choice) return undefined;
200
+
201
+ switch (choice.type) {
202
+ case 'auto': return 'auto';
203
+ case 'any': return 'required';
204
+ case 'tool': return { type: 'function', function: { name: choice.name } };
205
+ default: return 'auto';
206
+ }
207
+ }
@@ -0,0 +1,85 @@
1
+ import type {
2
+ AnthropicResponse,
3
+ AnthropicResponseBlock,
4
+ OpenAIResponse,
5
+ } from './types';
6
+ import { v4 as uuidv4 } from 'uuid';
7
+
8
+ /**
9
+ * Convert an OpenAI Chat Completions response to Anthropic Messages format.
10
+ * Used for non-streaming requests only.
11
+ */
12
+ export function convertResponse(
13
+ openaiResp: OpenAIResponse,
14
+ displayModel: string,
15
+ ): AnthropicResponse {
16
+ const choice = openaiResp.choices?.[0];
17
+ if (!choice) {
18
+ return {
19
+ id: `msg_${uuidv4().replace(/-/g, '')}`,
20
+ type: 'message',
21
+ role: 'assistant',
22
+ model: displayModel,
23
+ content: [{ type: 'text', text: 'No response from backend.' }],
24
+ stop_reason: 'end_turn',
25
+ stop_sequence: null,
26
+ usage: { input_tokens: 0, output_tokens: 0 },
27
+ };
28
+ }
29
+
30
+ const msg = choice.message;
31
+ const content: AnthropicResponseBlock[] = [];
32
+
33
+ // Reasoning / thinking content
34
+ if (msg.reasoning_content) {
35
+ content.push({ type: 'thinking', thinking: msg.reasoning_content });
36
+ }
37
+
38
+ // Text content
39
+ if (msg.content) {
40
+ content.push({ type: 'text', text: msg.content });
41
+ }
42
+
43
+ // Tool calls
44
+ if (msg.tool_calls && msg.tool_calls.length > 0) {
45
+ for (const tc of msg.tool_calls) {
46
+ let input: Record<string, unknown> = {};
47
+ try { input = JSON.parse(tc.function.arguments); } catch {}
48
+ content.push({
49
+ type: 'tool_use',
50
+ id: tc.id || `toolu_${uuidv4().replace(/-/g, '').slice(0, 24)}`,
51
+ name: tc.function.name,
52
+ input,
53
+ });
54
+ }
55
+ }
56
+
57
+ // If no content at all, add empty text
58
+ if (content.length === 0) {
59
+ content.push({ type: 'text', text: '' });
60
+ }
61
+
62
+ // Determine stop reason
63
+ let stopReason: AnthropicResponse['stop_reason'] = 'end_turn';
64
+ if (msg.tool_calls && msg.tool_calls.length > 0) {
65
+ stopReason = 'tool_use';
66
+ } else if (choice.finish_reason === 'length') {
67
+ stopReason = 'max_tokens';
68
+ }
69
+
70
+ const usage = openaiResp.usage || { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 };
71
+
72
+ return {
73
+ id: `msg_${uuidv4().replace(/-/g, '')}`,
74
+ type: 'message',
75
+ role: 'assistant',
76
+ model: displayModel,
77
+ content,
78
+ stop_reason: stopReason,
79
+ stop_sequence: null,
80
+ usage: {
81
+ input_tokens: usage.prompt_tokens,
82
+ output_tokens: usage.completion_tokens,
83
+ },
84
+ };
85
+ }