iobroker.javascript 10.4.0 → 10.4.1
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.
- package/README.md +3 -13
- package/admin/assets/AiChatPanel-CfjSsVpE.js +27 -0
- package/admin/assets/AiChatService-rFZwYNPk.js +216 -0
- package/admin/assets/AiInlineProvider-R_AVYwFT.js +5 -0
- package/admin/assets/{BlocklyEditor-DhwThchX.js → BlocklyEditor-DPJkoxjP.js} +3 -3
- package/admin/assets/{Compile-e8N5zI-J.js → Compile-BgTrxmPA.js} +1 -1
- package/admin/assets/{Debugger-BDQUKM1K.js → Debugger-CThCzBvP.js} +1 -1
- package/admin/assets/{FbEditor-BjLPNzpK.js → FbEditor-A7I5U0hA.js} +1 -1
- package/admin/assets/{RulesEditor--YX6ETsU.js → RulesEditor-MDkGXERp.js} +1 -1
- package/admin/assets/{ScriptEditor-BadPC8Mf.js → ScriptEditor-CqphehA8.js} +1 -1
- package/admin/assets/{ScriptEditorVanillaMonaco-BnAmy59d.js → ScriptEditorVanillaMonaco-CxgNa-3O.js} +3 -3
- package/admin/assets/aiPromptBuilder-CI2ChET0.js +1 -0
- package/admin/assets/{blocks_action-Ctn5A1AU.js → blocks_action-3CepYoiX.js} +1 -1
- package/admin/assets/{blocks_convert-BI0lf0sf.js → blocks_convert-BEpxVx-_.js} +1 -1
- package/admin/assets/{blocks_logic-o49klUX_.js → blocks_logic-CyIBT53p.js} +1 -1
- package/admin/assets/{blocks_number-DZtyWFyR.js → blocks_number-yqm4qwfh.js} +1 -1
- package/admin/assets/{blocks_object-BuzGCxVr.js → blocks_object-CizLkmWp.js} +1 -1
- package/admin/assets/{blocks_procedures-jn9sIYmR.js → blocks_procedures-pFTThYqz.js} +1 -1
- package/admin/assets/{blocks_sendto-Coui8w1H.js → blocks_sendto-rEq1xPmO.js} +1 -1
- package/admin/assets/{blocks_switch-CeRZizTM.js → blocks_switch-D3aybjqu.js} +1 -1
- package/admin/assets/{blocks_system-CaBPIC40.js → blocks_system-BVRsS2OE.js} +1 -1
- package/admin/assets/{blocks_text-DeDtPYWf.js → blocks_text-D93xQ_03.js} +1 -1
- package/admin/assets/{blocks_time-CTXQH3Lm.js → blocks_time-ChxhjgD7.js} +1 -1
- package/admin/assets/{blocks_timeout-D8IOKOb-.js → blocks_timeout-CUZ80HaP.js} +1 -1
- package/admin/assets/{blocks_trigger-zuuXJbhb.js → blocks_trigger-DivLpAuy.js} +1 -1
- package/admin/assets/{helpers-BwllSRzJ.js → helpers-Bt4VKZBw.js} +1 -1
- package/admin/assets/{index-Bmmscq2G.js → index-DzAQnPTD.js} +5 -5
- package/admin/assets/{loadBlocks-BcFtthDb.js → loadBlocks-DDLPEDa6.js} +1 -1
- package/admin/assets/{mf-entry-bootstrap-0-6db6c4fc.js → mf-entry-bootstrap-0-d055f05d.js} +1 -1
- package/admin/mf-stats.json +1 -1
- package/admin/tab.html +2 -2
- package/build/lib/ai.js +53 -0
- package/build/lib/ai.js.map +1 -0
- package/build/main.js +19 -618
- package/build/main.js.map +1 -1
- package/io-package.json +13 -13
- package/package.json +2 -1
- package/admin/assets/AiChatPanel-BjRIKaXG.js +0 -27
- package/admin/assets/AiChatService-DP1EmVk2.js +0 -216
- package/admin/assets/AiInlineProvider-jkEIJJJ-.js +0 -5
- package/admin/assets/aiPromptBuilder-Co6AqZwx.js +0 -1
- package/build/lib/aiProviderResolver.js +0 -260
- package/build/lib/aiProviderResolver.js.map +0 -1
- package/build/lib/anthropicAdapter.js +0 -212
- package/build/lib/anthropicAdapter.js.map +0 -1
|
@@ -1,260 +0,0 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
/**
|
|
3
|
-
* Helpers for resolving AI provider credentials in sendTo handlers.
|
|
4
|
-
*
|
|
5
|
-
* Keys are stored as `encryptedNative` + `protectedNative` in io-package.json —
|
|
6
|
-
* they are never sent from the frontend. The backend looks them up in `this.config`
|
|
7
|
-
* based on the `provider` name sent along with each `chatCompletion` or
|
|
8
|
-
* `testApiConnection` request.
|
|
9
|
-
*
|
|
10
|
-
* These functions are extracted so they can be unit-tested in isolation.
|
|
11
|
-
*/
|
|
12
|
-
Object.defineProperty(exports, "__esModule", { value: true });
|
|
13
|
-
exports.MAX_AI_MAX_TOKENS = exports.DEFAULT_AI_MAX_TOKENS = exports.MAX_AI_REQUEST_TIMEOUT_MS = exports.PROVIDER_CREDENTIAL_ID_FIELD = exports.PROVIDER_KEY_FIELD = void 0;
|
|
14
|
-
exports.getProviderCredentialId = getProviderCredentialId;
|
|
15
|
-
exports.resolveProviderCredentials = resolveProviderCredentials;
|
|
16
|
-
exports.resolveTestEndpoint = resolveTestEndpoint;
|
|
17
|
-
exports.resolveRequestTimeout = resolveRequestTimeout;
|
|
18
|
-
exports.resolveTestCredentials = resolveTestCredentials;
|
|
19
|
-
exports.listAvailableProviders = listAvailableProviders;
|
|
20
|
-
exports.extractAiResponseInfo = extractAiResponseInfo;
|
|
21
|
-
exports.describeEmptyAiResponse = describeEmptyAiResponse;
|
|
22
|
-
exports.resolveMaxTokens = resolveMaxTokens;
|
|
23
|
-
/** Maps each provider to the adapter-config field holding its API key. */
|
|
24
|
-
exports.PROVIDER_KEY_FIELD = {
|
|
25
|
-
openai: 'gptKey',
|
|
26
|
-
anthropic: 'claudeKey',
|
|
27
|
-
gemini: 'geminiKey',
|
|
28
|
-
deepseek: 'deepseekKey',
|
|
29
|
-
custom: 'gptBaseUrlKey',
|
|
30
|
-
};
|
|
31
|
-
/**
|
|
32
|
-
* Maps each provider to the adapter-config field holding the ID of its credential
|
|
33
|
-
* in the central credential store (used in `manager` mode).
|
|
34
|
-
*/
|
|
35
|
-
exports.PROVIDER_CREDENTIAL_ID_FIELD = {
|
|
36
|
-
openai: 'credentialIdGptKey',
|
|
37
|
-
anthropic: 'credentialIdClaudeKey',
|
|
38
|
-
gemini: 'credentialIdGeminiKey',
|
|
39
|
-
deepseek: 'credentialIdDeepseekKey',
|
|
40
|
-
custom: 'credentialIdGptBaseUrlKey',
|
|
41
|
-
};
|
|
42
|
-
/**
|
|
43
|
-
* Returns the configured credential ID (e.g. `system.credentials.anthropic`) for a provider
|
|
44
|
-
* in `manager` mode, or an empty string if none/unknown provider.
|
|
45
|
-
*/
|
|
46
|
-
function getProviderCredentialId(config, provider) {
|
|
47
|
-
const cfg = config || {};
|
|
48
|
-
const field = exports.PROVIDER_CREDENTIAL_ID_FIELD[provider];
|
|
49
|
-
return field ? (cfg[field] || '').toString().trim() : '';
|
|
50
|
-
}
|
|
51
|
-
/**
|
|
52
|
-
* Resolve API key and base URL for a provider from adapter config.
|
|
53
|
-
*
|
|
54
|
-
* Both come from the configuration and from nowhere else - above all not from the message that asked
|
|
55
|
-
* for them. The key that is resolved here travels with the request as its authorisation, so a caller
|
|
56
|
-
* who could name the address could have this instance carry the key to one of their own. An address
|
|
57
|
-
* out of a message used to win over the stored one, which was the comfortable way to put a proxy in
|
|
58
|
-
* front of OpenAI; a proxy belongs in the configuration now, entered once by whoever may configure
|
|
59
|
-
* this instance. The test button of the settings dialog is the one exception - see
|
|
60
|
-
* {@link resolveTestEndpoint}.
|
|
61
|
-
*
|
|
62
|
-
* The stored `gptBaseUrl` belongs to the `custom` provider and to no other. `openai` used to inherit
|
|
63
|
-
* it, which sent every request meant for api.openai.com - and the OpenAI key with it - to whatever
|
|
64
|
-
* host the custom endpoint pointed at (#2369).
|
|
65
|
-
*/
|
|
66
|
-
function resolveProviderCredentials(config, provider) {
|
|
67
|
-
const cfg = config || {};
|
|
68
|
-
const keyField = exports.PROVIDER_KEY_FIELD[provider];
|
|
69
|
-
const apiKey = keyField ? (cfg[keyField] || '').toString().trim() : '';
|
|
70
|
-
const baseUrl = provider === 'custom' ? (cfg.gptBaseUrl || '').toString().trim() : '';
|
|
71
|
-
return { apiKey, baseUrl };
|
|
72
|
-
}
|
|
73
|
-
/**
|
|
74
|
-
* The address the Test button of the settings dialog may try.
|
|
75
|
-
*
|
|
76
|
-
* The dialog tests what stands in the form rather than what was saved, because the first thing
|
|
77
|
-
* anybody does is type a key and press it. The one thing it must not do is make this instance carry a
|
|
78
|
-
* secret of its own to an address that came with the message, so an address out of the form counts
|
|
79
|
-
* only together with a key out of the form: one's own key to one's own endpoint gives nothing away.
|
|
80
|
-
* As soon as the key comes from this system - from the configuration or from the credential store -
|
|
81
|
-
* the address comes from there too. And only the OpenAI-compatible provider has an address to try;
|
|
82
|
-
* the form has no field for one anywhere else.
|
|
83
|
-
*
|
|
84
|
-
* @param config the adapter configuration
|
|
85
|
-
* @param provider the provider that is being tested
|
|
86
|
-
* @param form what the Test button sent
|
|
87
|
-
* @param form.apiKey the key that stands in the form, if any
|
|
88
|
-
* @param form.baseUrl the address that stands in the form, if any
|
|
89
|
-
*/
|
|
90
|
-
function resolveTestEndpoint(config, provider, form) {
|
|
91
|
-
const stored = resolveProviderCredentials(config, provider).baseUrl;
|
|
92
|
-
const formKey = (form.apiKey || '').toString().trim();
|
|
93
|
-
const formUrl = (form.baseUrl || '').toString().trim();
|
|
94
|
-
if (provider !== 'custom' || !formKey || !formUrl) {
|
|
95
|
-
return stored;
|
|
96
|
-
}
|
|
97
|
-
return formUrl;
|
|
98
|
-
}
|
|
99
|
-
/** Ceiling for an AI request, and the budget when the caller names none */
|
|
100
|
-
exports.MAX_AI_REQUEST_TIMEOUT_MS = 600_000;
|
|
101
|
-
/**
|
|
102
|
-
* How long to wait for an AI endpoint, from the `timeout` the caller put in the message.
|
|
103
|
-
*
|
|
104
|
-
* The inline completion asks for a short budget because it must not sit on the editor, the chat
|
|
105
|
-
* panel for a long one because a reasoning model takes its time. The field was sent all along but
|
|
106
|
-
* never read, so both of them waited out the full ten minutes.
|
|
107
|
-
*
|
|
108
|
-
* @param messageTimeout the value from the sendTo message, in milliseconds
|
|
109
|
-
*/
|
|
110
|
-
function resolveRequestTimeout(messageTimeout) {
|
|
111
|
-
const requested = parseInt(messageTimeout, 10);
|
|
112
|
-
// Nothing usable, or a zero - which is how Node itself spells "no timeout" - gets the ceiling
|
|
113
|
-
if (isNaN(requested) || requested <= 0) {
|
|
114
|
-
return exports.MAX_AI_REQUEST_TIMEOUT_MS;
|
|
115
|
-
}
|
|
116
|
-
// A second is the floor: below that not even a local model gets a chance to answer
|
|
117
|
-
return Math.min(Math.max(requested, 1000), exports.MAX_AI_REQUEST_TIMEOUT_MS);
|
|
118
|
-
}
|
|
119
|
-
/**
|
|
120
|
-
* For the testApiConnection sendTo command: if the caller supplied an apiKey
|
|
121
|
-
* (settings-dialog form value), use it; otherwise fall back to the stored key.
|
|
122
|
-
*/
|
|
123
|
-
function resolveTestCredentials(config, provider, messageApiKey, messageBaseUrl) {
|
|
124
|
-
const fallback = resolveProviderCredentials(config, provider);
|
|
125
|
-
const explicitKey = (messageApiKey || '').toString().trim();
|
|
126
|
-
return {
|
|
127
|
-
apiKey: explicitKey || fallback.apiKey,
|
|
128
|
-
// only together with a key of the form, and only for the endpoint of one's own
|
|
129
|
-
baseUrl: resolveTestEndpoint(config, provider, { apiKey: explicitKey, baseUrl: messageBaseUrl }),
|
|
130
|
-
};
|
|
131
|
-
}
|
|
132
|
-
/**
|
|
133
|
-
* List of providers that have credentials configured in this.config.
|
|
134
|
-
* Used by the `getAvailableAiProviders` sendTo command so the frontend
|
|
135
|
-
* knows which provider icons/models to show without ever seeing a key.
|
|
136
|
-
*/
|
|
137
|
-
function listAvailableProviders(config) {
|
|
138
|
-
const cfg = config || {};
|
|
139
|
-
const providers = [];
|
|
140
|
-
const manager = cfg.credentialType === 'manager';
|
|
141
|
-
// A key-based provider is "available" if it has a stored key (manual mode)
|
|
142
|
-
// or a selected credential ID (manager mode).
|
|
143
|
-
const has = (provider, key) => manager ? !!getProviderCredentialId(cfg, provider) : !!(key || '').trim();
|
|
144
|
-
if (has('openai', cfg.gptKey)) {
|
|
145
|
-
providers.push({ provider: 'openai' });
|
|
146
|
-
}
|
|
147
|
-
if (has('anthropic', cfg.claudeKey)) {
|
|
148
|
-
providers.push({ provider: 'anthropic' });
|
|
149
|
-
}
|
|
150
|
-
if (has('gemini', cfg.geminiKey)) {
|
|
151
|
-
providers.push({ provider: 'gemini' });
|
|
152
|
-
}
|
|
153
|
-
if (has('deepseek', cfg.deepseekKey)) {
|
|
154
|
-
providers.push({ provider: 'deepseek' });
|
|
155
|
-
}
|
|
156
|
-
// The custom/OpenAI-compatible endpoint is identified by its base URL (the key is optional,
|
|
157
|
-
// e.g. local Ollama), so its availability does not depend on the credential mode.
|
|
158
|
-
if ((cfg.gptBaseUrl || '').trim()) {
|
|
159
|
-
providers.push({ provider: 'custom', baseUrl: cfg.gptBaseUrl });
|
|
160
|
-
}
|
|
161
|
-
return providers;
|
|
162
|
-
}
|
|
163
|
-
function toCount(value) {
|
|
164
|
-
return typeof value === 'number' && isFinite(value) ? value : undefined;
|
|
165
|
-
}
|
|
166
|
-
/**
|
|
167
|
-
* Pull the "why did it stop" fields out of a chat-completion response.
|
|
168
|
-
*
|
|
169
|
-
* Anthropic and the OpenAI-compatible endpoints spell both the reason and the token counts
|
|
170
|
-
* differently. The adapter normalizes them and passes them on to the editor, so an answer that
|
|
171
|
-
* was cut off or filtered can say so instead of arriving as an empty chat bubble.
|
|
172
|
-
*/
|
|
173
|
-
function extractAiResponseInfo(parsed) {
|
|
174
|
-
const info = {};
|
|
175
|
-
const reason = parsed?.stop_reason ?? parsed?.choices?.[0]?.finish_reason;
|
|
176
|
-
if (typeof reason === 'string' && reason) {
|
|
177
|
-
info.finishReason = reason;
|
|
178
|
-
}
|
|
179
|
-
const input = toCount(parsed?.usage?.input_tokens ?? parsed?.usage?.prompt_tokens);
|
|
180
|
-
const output = toCount(parsed?.usage?.output_tokens ?? parsed?.usage?.completion_tokens);
|
|
181
|
-
if (input !== undefined || output !== undefined) {
|
|
182
|
-
info.usage = {
|
|
183
|
-
...(input !== undefined ? { input } : {}),
|
|
184
|
-
...(output !== undefined ? { output } : {}),
|
|
185
|
-
};
|
|
186
|
-
}
|
|
187
|
-
return info;
|
|
188
|
-
}
|
|
189
|
-
/**
|
|
190
|
-
* Why a 200 OK answer carried neither text nor a tool call.
|
|
191
|
-
*
|
|
192
|
-
* "Empty response from API" is true but useless - it sends the user looking for a broken key
|
|
193
|
-
* when the usual cause is a model that spent its whole output budget on reasoning tokens. The
|
|
194
|
-
* endpoint nearly always says why it stopped, so repeat that, and append a slice of the raw
|
|
195
|
-
* body for the cases where it does not.
|
|
196
|
-
*
|
|
197
|
-
* @param info the normalized stop reason and token counts
|
|
198
|
-
* @param rawBody the response body as it came off the wire
|
|
199
|
-
*/
|
|
200
|
-
function describeEmptyAiResponse(info, rawBody) {
|
|
201
|
-
let why;
|
|
202
|
-
switch (info.finishReason) {
|
|
203
|
-
case 'max_tokens':
|
|
204
|
-
case 'length':
|
|
205
|
-
why = 'the model used up its output budget before writing an answer';
|
|
206
|
-
break;
|
|
207
|
-
case 'content_filter':
|
|
208
|
-
case 'refusal':
|
|
209
|
-
why = 'the endpoint filtered the answer away';
|
|
210
|
-
break;
|
|
211
|
-
case 'tool_use':
|
|
212
|
-
case 'tool_calls':
|
|
213
|
-
why = 'the model wanted to call a tool, but the answer carried no readable tool call';
|
|
214
|
-
break;
|
|
215
|
-
default:
|
|
216
|
-
why = info.finishReason ? `the endpoint stopped with "${info.finishReason}"` : '';
|
|
217
|
-
break;
|
|
218
|
-
}
|
|
219
|
-
const details = [];
|
|
220
|
-
if (info.finishReason) {
|
|
221
|
-
details.push(`stop reason: ${info.finishReason}`);
|
|
222
|
-
}
|
|
223
|
-
if (info.usage?.input !== undefined || info.usage?.output !== undefined) {
|
|
224
|
-
details.push(`tokens in/out: ${info.usage?.input ?? '?'}/${info.usage?.output ?? '?'}`);
|
|
225
|
-
}
|
|
226
|
-
let message = 'The API answered without any content';
|
|
227
|
-
if (why) {
|
|
228
|
-
message += ` - ${why}`;
|
|
229
|
-
}
|
|
230
|
-
if (details.length) {
|
|
231
|
-
message += ` (${details.join(', ')})`;
|
|
232
|
-
}
|
|
233
|
-
// Only worth showing when the endpoint told us nothing usable at all
|
|
234
|
-
if (!info.finishReason && rawBody) {
|
|
235
|
-
message += `. Response: ${rawBody.substring(0, 200)}`;
|
|
236
|
-
}
|
|
237
|
-
return message;
|
|
238
|
-
}
|
|
239
|
-
/** What Anthropic gets as `max_tokens` when the setting is empty or unusable */
|
|
240
|
-
exports.DEFAULT_AI_MAX_TOKENS = 8192;
|
|
241
|
-
/** Highest `max_tokens` the setting may ask for - beyond this every current model answers 400 */
|
|
242
|
-
exports.MAX_AI_MAX_TOKENS = 200_000;
|
|
243
|
-
/**
|
|
244
|
-
* The output budget for an Anthropic request.
|
|
245
|
-
*
|
|
246
|
-
* Anthropic insists on `max_tokens`, so there is no "let the endpoint decide". Too small and a
|
|
247
|
-
* generated script is cut off mid-line; too large and the model rejects the request outright, so
|
|
248
|
-
* the configured value is clamped into a range every model can live with.
|
|
249
|
-
*
|
|
250
|
-
* @param configured the value from the adapter settings
|
|
251
|
-
*/
|
|
252
|
-
function resolveMaxTokens(configured) {
|
|
253
|
-
const requested = parseInt(configured, 10);
|
|
254
|
-
if (isNaN(requested) || requested <= 0) {
|
|
255
|
-
return exports.DEFAULT_AI_MAX_TOKENS;
|
|
256
|
-
}
|
|
257
|
-
// 1024 is the floor: below that not even a short answer with its reasoning fits
|
|
258
|
-
return Math.min(Math.max(requested, 1024), exports.MAX_AI_MAX_TOKENS);
|
|
259
|
-
}
|
|
260
|
-
//# sourceMappingURL=aiProviderResolver.js.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"aiProviderResolver.js","sourceRoot":"","sources":["../../src/lib/aiProviderResolver.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Helpers for resolving AI provider credentials in sendTo handlers.\n *\n * Keys are stored as `encryptedNative` + `protectedNative` in io-package.json —\n * they are never sent from the frontend. The backend looks them up in `this.config`\n * based on the `provider` name sent along with each `chatCompletion` or\n * `testApiConnection` request.\n *\n * These functions are extracted so they can be unit-tested in isolation.\n */\n\n/** Configuration subset that carries AI credentials. */\nexport interface AiConfigSlice {\n gptKey?: string;\n gptBaseUrl?: string;\n gptBaseUrlKey?: string;\n claudeKey?: string;\n geminiKey?: string;\n deepseekKey?: string;\n /**\n * Where the API keys come from:\n * - `manual`: keys are stored directly in the adapter config (encryptedNative)\n * - `manager`: the config only stores the ID of a credential in the central\n * ioBroker credential store (`system.credentials.*`), resolved at runtime\n */\n credentialType?: 'manual' | 'manager';\n credentialIdGptKey?: string;\n credentialIdClaudeKey?: string;\n credentialIdGeminiKey?: string;\n credentialIdDeepseekKey?: string;\n credentialIdGptBaseUrlKey?: string;\n}\n\nexport type AiProvider = 'openai' | 'anthropic' | 'gemini' | 'deepseek' | 'custom';\n\n/** Maps each provider to the adapter-config field holding its API key. */\nexport const PROVIDER_KEY_FIELD: Record<AiProvider, keyof AiConfigSlice> = {\n openai: 'gptKey',\n anthropic: 'claudeKey',\n gemini: 'geminiKey',\n deepseek: 'deepseekKey',\n custom: 'gptBaseUrlKey',\n};\n\n/**\n * Maps each provider to the adapter-config field holding the ID of its credential\n * in the central credential store (used in `manager` mode).\n */\nexport const PROVIDER_CREDENTIAL_ID_FIELD: Record<AiProvider, keyof AiConfigSlice> = {\n openai: 'credentialIdGptKey',\n anthropic: 'credentialIdClaudeKey',\n gemini: 'credentialIdGeminiKey',\n deepseek: 'credentialIdDeepseekKey',\n custom: 'credentialIdGptBaseUrlKey',\n};\n\n/**\n * Returns the configured credential ID (e.g. `system.credentials.anthropic`) for a provider\n * in `manager` mode, or an empty string if none/unknown provider.\n */\nexport function getProviderCredentialId(config: AiConfigSlice | undefined | null, provider: string): string {\n const cfg = config || {};\n const field = PROVIDER_CREDENTIAL_ID_FIELD[provider as AiProvider];\n return field ? (cfg[field] || '').toString().trim() : '';\n}\n\n/**\n * Resolve API key and base URL for a provider from adapter config.\n *\n * Both come from the configuration and from nowhere else - above all not from the message that asked\n * for them. The key that is resolved here travels with the request as its authorisation, so a caller\n * who could name the address could have this instance carry the key to one of their own. An address\n * out of a message used to win over the stored one, which was the comfortable way to put a proxy in\n * front of OpenAI; a proxy belongs in the configuration now, entered once by whoever may configure\n * this instance. The test button of the settings dialog is the one exception - see\n * {@link resolveTestEndpoint}.\n *\n * The stored `gptBaseUrl` belongs to the `custom` provider and to no other. `openai` used to inherit\n * it, which sent every request meant for api.openai.com - and the OpenAI key with it - to whatever\n * host the custom endpoint pointed at (#2369).\n */\nexport function resolveProviderCredentials(\n config: AiConfigSlice | undefined | null,\n provider: string,\n): { apiKey: string; baseUrl: string } {\n const cfg = config || {};\n const keyField = PROVIDER_KEY_FIELD[provider as AiProvider];\n const apiKey = keyField ? (cfg[keyField] || '').toString().trim() : '';\n const baseUrl = provider === 'custom' ? (cfg.gptBaseUrl || '').toString().trim() : '';\n return { apiKey, baseUrl };\n}\n\n/**\n * The address the Test button of the settings dialog may try.\n *\n * The dialog tests what stands in the form rather than what was saved, because the first thing\n * anybody does is type a key and press it. The one thing it must not do is make this instance carry a\n * secret of its own to an address that came with the message, so an address out of the form counts\n * only together with a key out of the form: one's own key to one's own endpoint gives nothing away.\n * As soon as the key comes from this system - from the configuration or from the credential store -\n * the address comes from there too. And only the OpenAI-compatible provider has an address to try;\n * the form has no field for one anywhere else.\n *\n * @param config the adapter configuration\n * @param provider the provider that is being tested\n * @param form what the Test button sent\n * @param form.apiKey the key that stands in the form, if any\n * @param form.baseUrl the address that stands in the form, if any\n */\nexport function resolveTestEndpoint(\n config: AiConfigSlice | undefined | null,\n provider: string,\n form: { apiKey?: string; baseUrl?: string },\n): string {\n const stored = resolveProviderCredentials(config, provider).baseUrl;\n const formKey = (form.apiKey || '').toString().trim();\n const formUrl = (form.baseUrl || '').toString().trim();\n if (provider !== 'custom' || !formKey || !formUrl) {\n return stored;\n }\n return formUrl;\n}\n\n/** Ceiling for an AI request, and the budget when the caller names none */\nexport const MAX_AI_REQUEST_TIMEOUT_MS = 600_000;\n\n/**\n * How long to wait for an AI endpoint, from the `timeout` the caller put in the message.\n *\n * The inline completion asks for a short budget because it must not sit on the editor, the chat\n * panel for a long one because a reasoning model takes its time. The field was sent all along but\n * never read, so both of them waited out the full ten minutes.\n *\n * @param messageTimeout the value from the sendTo message, in milliseconds\n */\nexport function resolveRequestTimeout(messageTimeout?: unknown): number {\n const requested = parseInt(messageTimeout as string, 10);\n // Nothing usable, or a zero - which is how Node itself spells \"no timeout\" - gets the ceiling\n if (isNaN(requested) || requested <= 0) {\n return MAX_AI_REQUEST_TIMEOUT_MS;\n }\n // A second is the floor: below that not even a local model gets a chance to answer\n return Math.min(Math.max(requested, 1000), MAX_AI_REQUEST_TIMEOUT_MS);\n}\n\n/**\n * For the testApiConnection sendTo command: if the caller supplied an apiKey\n * (settings-dialog form value), use it; otherwise fall back to the stored key.\n */\nexport function resolveTestCredentials(\n config: AiConfigSlice | undefined | null,\n provider: string,\n messageApiKey?: string,\n messageBaseUrl?: string,\n): { apiKey: string; baseUrl: string } {\n const fallback = resolveProviderCredentials(config, provider);\n const explicitKey = (messageApiKey || '').toString().trim();\n return {\n apiKey: explicitKey || fallback.apiKey,\n // only together with a key of the form, and only for the endpoint of one's own\n baseUrl: resolveTestEndpoint(config, provider, { apiKey: explicitKey, baseUrl: messageBaseUrl }),\n };\n}\n\n/**\n * List of providers that have credentials configured in this.config.\n * Used by the `getAvailableAiProviders` sendTo command so the frontend\n * knows which provider icons/models to show without ever seeing a key.\n */\nexport function listAvailableProviders(\n config: AiConfigSlice | undefined | null,\n): { provider: AiProvider; baseUrl?: string }[] {\n const cfg = config || {};\n const providers: { provider: AiProvider; baseUrl?: string }[] = [];\n const manager = cfg.credentialType === 'manager';\n // A key-based provider is \"available\" if it has a stored key (manual mode)\n // or a selected credential ID (manager mode).\n const has = (provider: AiProvider, key: string | undefined | null): boolean =>\n manager ? !!getProviderCredentialId(cfg, provider) : !!(key || '').trim();\n if (has('openai', cfg.gptKey)) {\n providers.push({ provider: 'openai' });\n }\n if (has('anthropic', cfg.claudeKey)) {\n providers.push({ provider: 'anthropic' });\n }\n if (has('gemini', cfg.geminiKey)) {\n providers.push({ provider: 'gemini' });\n }\n if (has('deepseek', cfg.deepseekKey)) {\n providers.push({ provider: 'deepseek' });\n }\n // The custom/OpenAI-compatible endpoint is identified by its base URL (the key is optional,\n // e.g. local Ollama), so its availability does not depend on the credential mode.\n if ((cfg.gptBaseUrl || '').trim()) {\n providers.push({ provider: 'custom', baseUrl: cfg.gptBaseUrl });\n }\n return providers;\n}\n\n/** What a chat endpoint reported about how it finished a completion. */\nexport interface AiResponseInfo {\n /** Anthropic's `stop_reason` or the OpenAI-compatible `finish_reason`, when the endpoint sends one */\n finishReason?: string;\n /** Token counts, as far as the endpoint reports them */\n usage?: { input?: number; output?: number };\n}\n\n/** The handful of fields of a chat-completion response that say how it ended. */\nexport interface AiRawResponse {\n stop_reason?: unknown;\n choices?: { finish_reason?: unknown }[];\n usage?: {\n input_tokens?: unknown;\n output_tokens?: unknown;\n prompt_tokens?: unknown;\n completion_tokens?: unknown;\n };\n}\n\nfunction toCount(value: unknown): number | undefined {\n return typeof value === 'number' && isFinite(value) ? value : undefined;\n}\n\n/**\n * Pull the \"why did it stop\" fields out of a chat-completion response.\n *\n * Anthropic and the OpenAI-compatible endpoints spell both the reason and the token counts\n * differently. The adapter normalizes them and passes them on to the editor, so an answer that\n * was cut off or filtered can say so instead of arriving as an empty chat bubble.\n */\nexport function extractAiResponseInfo(parsed: AiRawResponse | null | undefined): AiResponseInfo {\n const info: AiResponseInfo = {};\n const reason = parsed?.stop_reason ?? parsed?.choices?.[0]?.finish_reason;\n if (typeof reason === 'string' && reason) {\n info.finishReason = reason;\n }\n const input = toCount(parsed?.usage?.input_tokens ?? parsed?.usage?.prompt_tokens);\n const output = toCount(parsed?.usage?.output_tokens ?? parsed?.usage?.completion_tokens);\n if (input !== undefined || output !== undefined) {\n info.usage = {\n ...(input !== undefined ? { input } : {}),\n ...(output !== undefined ? { output } : {}),\n };\n }\n return info;\n}\n\n/**\n * Why a 200 OK answer carried neither text nor a tool call.\n *\n * \"Empty response from API\" is true but useless - it sends the user looking for a broken key\n * when the usual cause is a model that spent its whole output budget on reasoning tokens. The\n * endpoint nearly always says why it stopped, so repeat that, and append a slice of the raw\n * body for the cases where it does not.\n *\n * @param info the normalized stop reason and token counts\n * @param rawBody the response body as it came off the wire\n */\nexport function describeEmptyAiResponse(info: AiResponseInfo, rawBody?: string): string {\n let why: string;\n switch (info.finishReason) {\n case 'max_tokens':\n case 'length':\n why = 'the model used up its output budget before writing an answer';\n break;\n case 'content_filter':\n case 'refusal':\n why = 'the endpoint filtered the answer away';\n break;\n case 'tool_use':\n case 'tool_calls':\n why = 'the model wanted to call a tool, but the answer carried no readable tool call';\n break;\n default:\n why = info.finishReason ? `the endpoint stopped with \"${info.finishReason}\"` : '';\n break;\n }\n const details: string[] = [];\n if (info.finishReason) {\n details.push(`stop reason: ${info.finishReason}`);\n }\n if (info.usage?.input !== undefined || info.usage?.output !== undefined) {\n details.push(`tokens in/out: ${info.usage?.input ?? '?'}/${info.usage?.output ?? '?'}`);\n }\n let message = 'The API answered without any content';\n if (why) {\n message += ` - ${why}`;\n }\n if (details.length) {\n message += ` (${details.join(', ')})`;\n }\n // Only worth showing when the endpoint told us nothing usable at all\n if (!info.finishReason && rawBody) {\n message += `. Response: ${rawBody.substring(0, 200)}`;\n }\n return message;\n}\n\n/** What Anthropic gets as `max_tokens` when the setting is empty or unusable */\nexport const DEFAULT_AI_MAX_TOKENS = 8192;\n/** Highest `max_tokens` the setting may ask for - beyond this every current model answers 400 */\nexport const MAX_AI_MAX_TOKENS = 200_000;\n\n/**\n * The output budget for an Anthropic request.\n *\n * Anthropic insists on `max_tokens`, so there is no \"let the endpoint decide\". Too small and a\n * generated script is cut off mid-line; too large and the model rejects the request outright, so\n * the configured value is clamped into a range every model can live with.\n *\n * @param configured the value from the adapter settings\n */\nexport function resolveMaxTokens(configured?: unknown): number {\n const requested = parseInt(configured as string, 10);\n if (isNaN(requested) || requested <= 0) {\n return DEFAULT_AI_MAX_TOKENS;\n }\n // 1024 is the floor: below that not even a short answer with its reasoning fits\n return Math.min(Math.max(requested, 1024), MAX_AI_MAX_TOKENS);\n}\n"]}
|
|
@@ -1,212 +0,0 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
/**
|
|
3
|
-
* Translation layer between OpenAI-style chat-completion messages/tools and
|
|
4
|
-
* Anthropic's native Messages API.
|
|
5
|
-
*
|
|
6
|
-
* The frontend (AiChatService / useAiChat) always speaks the OpenAI format:
|
|
7
|
-
* - tools[] = [{ type: 'function', function: { name, description, parameters } }]
|
|
8
|
-
* - assistant message with tool_calls[] = [{ id, type: 'function', function: { name, arguments } }]
|
|
9
|
-
* - tool-result message with { role: 'tool', tool_call_id, content }
|
|
10
|
-
* - response = { content, tool_calls? }
|
|
11
|
-
*
|
|
12
|
-
* Anthropic's API uses a different shape:
|
|
13
|
-
* - tools[] = [{ name, description, input_schema }]
|
|
14
|
-
* - assistant message content = [{ type: 'text', text }, { type: 'tool_use', id, name, input }]
|
|
15
|
-
* - tool-result = user message with content = [{ type: 'tool_result', tool_use_id, content }]
|
|
16
|
-
* - response body = { content: [text/tool_use blocks], stop_reason }
|
|
17
|
-
*
|
|
18
|
-
* These functions are pure so they can be unit-tested in isolation.
|
|
19
|
-
*/
|
|
20
|
-
Object.defineProperty(exports, "__esModule", { value: true });
|
|
21
|
-
exports.translateToolsToAnthropic = translateToolsToAnthropic;
|
|
22
|
-
exports.translateMessagesToAnthropic = translateMessagesToAnthropic;
|
|
23
|
-
exports.translateAnthropicResponseToOpenAI = translateAnthropicResponseToOpenAI;
|
|
24
|
-
exports.describeAnthropicContent = describeAnthropicContent;
|
|
25
|
-
/** Translate OpenAI function-tool definitions to Anthropic tool definitions. */
|
|
26
|
-
function translateToolsToAnthropic(tools) {
|
|
27
|
-
if (!Array.isArray(tools)) {
|
|
28
|
-
return [];
|
|
29
|
-
}
|
|
30
|
-
const result = [];
|
|
31
|
-
for (const t of tools) {
|
|
32
|
-
// Only `function`-type tools are supported. Anthropic uses a flat shape.
|
|
33
|
-
const tool = t;
|
|
34
|
-
const fn = tool?.function;
|
|
35
|
-
if (!fn?.name) {
|
|
36
|
-
continue;
|
|
37
|
-
}
|
|
38
|
-
result.push({
|
|
39
|
-
name: fn.name,
|
|
40
|
-
description: fn.description,
|
|
41
|
-
// OpenAI's `parameters` and Anthropic's `input_schema` share the JSON Schema shape.
|
|
42
|
-
// If none is provided, Anthropic still requires an object schema.
|
|
43
|
-
input_schema: fn.parameters || { type: 'object', properties: {} },
|
|
44
|
-
});
|
|
45
|
-
}
|
|
46
|
-
return result;
|
|
47
|
-
}
|
|
48
|
-
/** Safe JSON.parse that returns an object on failure so we never throw mid-translation. */
|
|
49
|
-
function safeParseArgs(args) {
|
|
50
|
-
if (!args) {
|
|
51
|
-
return {};
|
|
52
|
-
}
|
|
53
|
-
try {
|
|
54
|
-
const parsed = JSON.parse(args);
|
|
55
|
-
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
56
|
-
return parsed;
|
|
57
|
-
}
|
|
58
|
-
return {};
|
|
59
|
-
}
|
|
60
|
-
catch {
|
|
61
|
-
return {};
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
/**
|
|
65
|
-
* Translate a flat OpenAI-style message array into Anthropic's content-block format.
|
|
66
|
-
* System messages are extracted and returned separately (Anthropic takes `system`
|
|
67
|
-
* as a top-level request field, not an inline message).
|
|
68
|
-
*/
|
|
69
|
-
function translateMessagesToAnthropic(messages) {
|
|
70
|
-
if (!Array.isArray(messages)) {
|
|
71
|
-
return { system: '', messages: [] };
|
|
72
|
-
}
|
|
73
|
-
const systemChunks = [];
|
|
74
|
-
const out = [];
|
|
75
|
-
// Pending tool-results that must be grouped into a single `user` message per Anthropic's rules.
|
|
76
|
-
let pendingToolResults = [];
|
|
77
|
-
const flushToolResults = () => {
|
|
78
|
-
if (pendingToolResults.length) {
|
|
79
|
-
out.push({ role: 'user', content: pendingToolResults });
|
|
80
|
-
pendingToolResults = [];
|
|
81
|
-
}
|
|
82
|
-
};
|
|
83
|
-
for (const m of messages) {
|
|
84
|
-
if (!m || typeof m !== 'object') {
|
|
85
|
-
continue;
|
|
86
|
-
}
|
|
87
|
-
if (m.role === 'system') {
|
|
88
|
-
if (typeof m.content === 'string' && m.content) {
|
|
89
|
-
systemChunks.push(m.content);
|
|
90
|
-
}
|
|
91
|
-
continue;
|
|
92
|
-
}
|
|
93
|
-
if (m.role === 'tool') {
|
|
94
|
-
// Tool results accumulate; they're flushed when the next non-tool message appears.
|
|
95
|
-
pendingToolResults.push({
|
|
96
|
-
type: 'tool_result',
|
|
97
|
-
tool_use_id: m.tool_call_id || '',
|
|
98
|
-
content: typeof m.content === 'string' ? m.content : JSON.stringify(m.content ?? ''),
|
|
99
|
-
});
|
|
100
|
-
continue;
|
|
101
|
-
}
|
|
102
|
-
flushToolResults();
|
|
103
|
-
if (m.role === 'assistant') {
|
|
104
|
-
const blocks = [];
|
|
105
|
-
if (typeof m.content === 'string' && m.content) {
|
|
106
|
-
blocks.push({ type: 'text', text: m.content });
|
|
107
|
-
}
|
|
108
|
-
if (Array.isArray(m.tool_calls)) {
|
|
109
|
-
for (const tc of m.tool_calls) {
|
|
110
|
-
if (!tc?.id || !tc.function?.name) {
|
|
111
|
-
continue;
|
|
112
|
-
}
|
|
113
|
-
blocks.push({
|
|
114
|
-
type: 'tool_use',
|
|
115
|
-
id: tc.id,
|
|
116
|
-
name: tc.function.name,
|
|
117
|
-
input: safeParseArgs(tc.function.arguments),
|
|
118
|
-
});
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
// Anthropic requires a non-empty content array for assistant messages.
|
|
122
|
-
if (blocks.length) {
|
|
123
|
-
out.push({ role: 'assistant', content: blocks });
|
|
124
|
-
}
|
|
125
|
-
continue;
|
|
126
|
-
}
|
|
127
|
-
if (m.role === 'user') {
|
|
128
|
-
// Plain text user message. Content blocks aren't needed here.
|
|
129
|
-
const text = typeof m.content === 'string' ? m.content : '';
|
|
130
|
-
if (text) {
|
|
131
|
-
out.push({ role: 'user', content: text });
|
|
132
|
-
}
|
|
133
|
-
continue;
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
flushToolResults();
|
|
137
|
-
return { system: systemChunks.join('\n\n'), messages: out };
|
|
138
|
-
}
|
|
139
|
-
/**
|
|
140
|
-
* Translate an Anthropic Messages API response back into the OpenAI-style
|
|
141
|
-
* `{ content, tool_calls }` shape that our frontend already understands.
|
|
142
|
-
*/
|
|
143
|
-
function translateAnthropicResponseToOpenAI(response) {
|
|
144
|
-
if (!response || !Array.isArray(response.content)) {
|
|
145
|
-
return { content: '' };
|
|
146
|
-
}
|
|
147
|
-
const textParts = [];
|
|
148
|
-
const toolCalls = [];
|
|
149
|
-
for (const block of response.content) {
|
|
150
|
-
if (!block || typeof block !== 'object') {
|
|
151
|
-
continue;
|
|
152
|
-
}
|
|
153
|
-
if (block.type === 'text' && typeof block.text === 'string') {
|
|
154
|
-
textParts.push(block.text);
|
|
155
|
-
}
|
|
156
|
-
else if (block.type === 'tool_use') {
|
|
157
|
-
const tu = block;
|
|
158
|
-
toolCalls.push({
|
|
159
|
-
id: tu.id || '',
|
|
160
|
-
type: 'function',
|
|
161
|
-
function: {
|
|
162
|
-
name: tu.name || '',
|
|
163
|
-
arguments: JSON.stringify(tu.input ?? {}),
|
|
164
|
-
},
|
|
165
|
-
});
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
const result = {
|
|
169
|
-
content: textParts.join('\n'),
|
|
170
|
-
};
|
|
171
|
-
if (toolCalls.length) {
|
|
172
|
-
result.tool_calls = toolCalls;
|
|
173
|
-
}
|
|
174
|
-
return result;
|
|
175
|
-
}
|
|
176
|
-
/**
|
|
177
|
-
* Name the content blocks an answer consists of, for the log.
|
|
178
|
-
*
|
|
179
|
-
* When the translation above comes back empty although the endpoint reported a normal end and a
|
|
180
|
-
* few hundred output tokens, the whole question is *what* it sent instead - a `thinking` block, a
|
|
181
|
-
* block type that did not exist when this was written, an empty text. Without this the log can
|
|
182
|
-
* only say "no content", which is the one thing already known.
|
|
183
|
-
*
|
|
184
|
-
* @param response the parsed answer of the Anthropic API
|
|
185
|
-
*/
|
|
186
|
-
function describeAnthropicContent(response) {
|
|
187
|
-
if (!response) {
|
|
188
|
-
return 'no response';
|
|
189
|
-
}
|
|
190
|
-
if (!Array.isArray(response.content)) {
|
|
191
|
-
return `content is ${typeof response.content}`;
|
|
192
|
-
}
|
|
193
|
-
if (!response.content.length) {
|
|
194
|
-
return 'no content blocks';
|
|
195
|
-
}
|
|
196
|
-
return response.content
|
|
197
|
-
.map(block => {
|
|
198
|
-
if (!block || typeof block !== 'object') {
|
|
199
|
-
return typeof block;
|
|
200
|
-
}
|
|
201
|
-
const type = block.type;
|
|
202
|
-
const name = typeof type === 'string' ? type : 'untyped';
|
|
203
|
-
// An empty text block is a different problem from a block nobody reads
|
|
204
|
-
if (name === 'text') {
|
|
205
|
-
const text = block.text;
|
|
206
|
-
return typeof text === 'string' ? `text(${text.length} chars)` : `text(${typeof text})`;
|
|
207
|
-
}
|
|
208
|
-
return name;
|
|
209
|
-
})
|
|
210
|
-
.join(', ');
|
|
211
|
-
}
|
|
212
|
-
//# sourceMappingURL=anthropicAdapter.js.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"anthropicAdapter.js","sourceRoot":"","sources":["../../src/lib/anthropicAdapter.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Translation layer between OpenAI-style chat-completion messages/tools and\n * Anthropic's native Messages API.\n *\n * The frontend (AiChatService / useAiChat) always speaks the OpenAI format:\n * - tools[] = [{ type: 'function', function: { name, description, parameters } }]\n * - assistant message with tool_calls[] = [{ id, type: 'function', function: { name, arguments } }]\n * - tool-result message with { role: 'tool', tool_call_id, content }\n * - response = { content, tool_calls? }\n *\n * Anthropic's API uses a different shape:\n * - tools[] = [{ name, description, input_schema }]\n * - assistant message content = [{ type: 'text', text }, { type: 'tool_use', id, name, input }]\n * - tool-result = user message with content = [{ type: 'tool_result', tool_use_id, content }]\n * - response body = { content: [text/tool_use blocks], stop_reason }\n *\n * These functions are pure so they can be unit-tested in isolation.\n */\n\nexport interface OpenAITool {\n type: 'function';\n function: {\n name: string;\n description?: string;\n parameters?: unknown;\n };\n}\n\nexport interface AnthropicTool {\n name: string;\n description?: string;\n input_schema: unknown;\n}\n\nexport interface OpenAIToolCall {\n id: string;\n type: 'function';\n function: {\n name: string;\n arguments: string;\n };\n}\n\nexport interface OpenAIMessage {\n role: 'system' | 'user' | 'assistant' | 'tool';\n content?: string | null;\n tool_calls?: OpenAIToolCall[];\n tool_call_id?: string;\n name?: string;\n}\n\ntype AnthropicContentBlock =\n | { type: 'text'; text: string }\n | { type: 'tool_use'; id: string; name: string; input: Record<string, unknown> }\n | { type: 'tool_result'; tool_use_id: string; content: string; is_error?: boolean };\n\nexport interface AnthropicMessage {\n role: 'user' | 'assistant';\n content: string | AnthropicContentBlock[];\n}\n\nexport interface AnthropicResponse {\n content?: AnthropicContentBlock[];\n stop_reason?: string;\n [key: string]: unknown;\n}\n\n/** Translate OpenAI function-tool definitions to Anthropic tool definitions. */\nexport function translateToolsToAnthropic(tools: unknown[] | undefined | null): AnthropicTool[] {\n if (!Array.isArray(tools)) {\n return [];\n }\n const result: AnthropicTool[] = [];\n for (const t of tools) {\n // Only `function`-type tools are supported. Anthropic uses a flat shape.\n const tool = t as OpenAITool;\n const fn = tool?.function;\n if (!fn?.name) {\n continue;\n }\n result.push({\n name: fn.name,\n description: fn.description,\n // OpenAI's `parameters` and Anthropic's `input_schema` share the JSON Schema shape.\n // If none is provided, Anthropic still requires an object schema.\n input_schema: fn.parameters || { type: 'object', properties: {} },\n });\n }\n return result;\n}\n\n/** Safe JSON.parse that returns an object on failure so we never throw mid-translation. */\nfunction safeParseArgs(args: string | undefined): Record<string, unknown> {\n if (!args) {\n return {};\n }\n try {\n const parsed: unknown = JSON.parse(args);\n if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {\n return parsed as Record<string, unknown>;\n }\n return {};\n } catch {\n return {};\n }\n}\n\n/**\n * Translate a flat OpenAI-style message array into Anthropic's content-block format.\n * System messages are extracted and returned separately (Anthropic takes `system`\n * as a top-level request field, not an inline message).\n */\nexport function translateMessagesToAnthropic(messages: OpenAIMessage[] | undefined | null): {\n system: string;\n messages: AnthropicMessage[];\n} {\n if (!Array.isArray(messages)) {\n return { system: '', messages: [] };\n }\n\n const systemChunks: string[] = [];\n const out: AnthropicMessage[] = [];\n // Pending tool-results that must be grouped into a single `user` message per Anthropic's rules.\n let pendingToolResults: AnthropicContentBlock[] = [];\n\n const flushToolResults = (): void => {\n if (pendingToolResults.length) {\n out.push({ role: 'user', content: pendingToolResults });\n pendingToolResults = [];\n }\n };\n\n for (const m of messages) {\n if (!m || typeof m !== 'object') {\n continue;\n }\n\n if (m.role === 'system') {\n if (typeof m.content === 'string' && m.content) {\n systemChunks.push(m.content);\n }\n continue;\n }\n\n if (m.role === 'tool') {\n // Tool results accumulate; they're flushed when the next non-tool message appears.\n pendingToolResults.push({\n type: 'tool_result',\n tool_use_id: m.tool_call_id || '',\n content: typeof m.content === 'string' ? m.content : JSON.stringify(m.content ?? ''),\n });\n continue;\n }\n\n flushToolResults();\n\n if (m.role === 'assistant') {\n const blocks: AnthropicContentBlock[] = [];\n if (typeof m.content === 'string' && m.content) {\n blocks.push({ type: 'text', text: m.content });\n }\n if (Array.isArray(m.tool_calls)) {\n for (const tc of m.tool_calls) {\n if (!tc?.id || !tc.function?.name) {\n continue;\n }\n blocks.push({\n type: 'tool_use',\n id: tc.id,\n name: tc.function.name,\n input: safeParseArgs(tc.function.arguments),\n });\n }\n }\n // Anthropic requires a non-empty content array for assistant messages.\n if (blocks.length) {\n out.push({ role: 'assistant', content: blocks });\n }\n continue;\n }\n\n if (m.role === 'user') {\n // Plain text user message. Content blocks aren't needed here.\n const text = typeof m.content === 'string' ? m.content : '';\n if (text) {\n out.push({ role: 'user', content: text });\n }\n continue;\n }\n }\n\n flushToolResults();\n\n return { system: systemChunks.join('\\n\\n'), messages: out };\n}\n\n/**\n * Translate an Anthropic Messages API response back into the OpenAI-style\n * `{ content, tool_calls }` shape that our frontend already understands.\n */\nexport function translateAnthropicResponseToOpenAI(response: AnthropicResponse | undefined | null): {\n content: string;\n tool_calls?: OpenAIToolCall[];\n} {\n if (!response || !Array.isArray(response.content)) {\n return { content: '' };\n }\n const textParts: string[] = [];\n const toolCalls: OpenAIToolCall[] = [];\n for (const block of response.content) {\n if (!block || typeof block !== 'object') {\n continue;\n }\n if (block.type === 'text' && typeof (block as { text?: unknown }).text === 'string') {\n textParts.push((block as { text: string }).text);\n } else if (block.type === 'tool_use') {\n const tu = block as { id?: string; name?: string; input?: unknown };\n toolCalls.push({\n id: tu.id || '',\n type: 'function',\n function: {\n name: tu.name || '',\n arguments: JSON.stringify(tu.input ?? {}),\n },\n });\n }\n }\n const result: { content: string; tool_calls?: OpenAIToolCall[] } = {\n content: textParts.join('\\n'),\n };\n if (toolCalls.length) {\n result.tool_calls = toolCalls;\n }\n return result;\n}\n\n/**\n * Name the content blocks an answer consists of, for the log.\n *\n * When the translation above comes back empty although the endpoint reported a normal end and a\n * few hundred output tokens, the whole question is *what* it sent instead - a `thinking` block, a\n * block type that did not exist when this was written, an empty text. Without this the log can\n * only say \"no content\", which is the one thing already known.\n *\n * @param response the parsed answer of the Anthropic API\n */\nexport function describeAnthropicContent(response: AnthropicResponse | undefined | null): string {\n if (!response) {\n return 'no response';\n }\n if (!Array.isArray(response.content)) {\n return `content is ${typeof response.content}`;\n }\n if (!response.content.length) {\n return 'no content blocks';\n }\n return response.content\n .map(block => {\n if (!block || typeof block !== 'object') {\n return typeof block;\n }\n const type = (block as { type?: unknown }).type;\n const name = typeof type === 'string' ? type : 'untyped';\n // An empty text block is a different problem from a block nobody reads\n if (name === 'text') {\n const text = (block as { text?: unknown }).text;\n return typeof text === 'string' ? `text(${text.length} chars)` : `text(${typeof text})`;\n }\n return name;\n })\n .join(', ');\n}\n"]}
|