@yeaft/webchat-agent 0.1.596 → 0.1.597
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/package.json +1 -1
- package/unify/llm/adapter.js +24 -23
- package/unify/llm/router.js +27 -23
- package/unify/models.js +14 -14
- package/unify/llm/chat-completions.js +0 -501
package/package.json
CHANGED
package/unify/llm/adapter.js
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* adapter.js — LLM Adapter base class, unified types, and factory
|
|
3
3
|
*
|
|
4
|
-
* Design decision (2026-04-
|
|
5
|
-
* 1. AnthropicAdapter
|
|
6
|
-
* 2.
|
|
4
|
+
* Design decision (Phase 7, 2026-04-27): Only two adapters remain:
|
|
5
|
+
* 1. AnthropicAdapter — Anthropic Messages API
|
|
6
|
+
* 2. OpenAIResponsesAdapter — OpenAI Responses API (covers GPT, DeepSeek, CopilotProxy, etc.)
|
|
7
|
+
*
|
|
8
|
+
* The legacy ChatCompletionsAdapter (OpenAI Chat Completions API) was deleted in
|
|
9
|
+
* Phase 7. Configurations using protocol "openai" or alias "chat-completions"
|
|
10
|
+
* must migrate to "openai-responses" or "anthropic".
|
|
7
11
|
*
|
|
8
12
|
* The engine sees only unified types — it never knows which API is underneath.
|
|
9
13
|
*/
|
|
@@ -234,8 +238,7 @@ export async function createLLMAdapter(config) {
|
|
|
234
238
|
}
|
|
235
239
|
|
|
236
240
|
// ─── Legacy path: single adapter from env vars ────────
|
|
237
|
-
|
|
238
|
-
const adapter = config.adapter === 'chat-completions' ? 'openai' : config.adapter;
|
|
241
|
+
const adapter = config.adapter;
|
|
239
242
|
|
|
240
243
|
if (adapter === 'anthropic' || (!adapter && config.apiKey)) {
|
|
241
244
|
if (!config.apiKey) {
|
|
@@ -248,26 +251,24 @@ export async function createLLMAdapter(config) {
|
|
|
248
251
|
});
|
|
249
252
|
}
|
|
250
253
|
|
|
251
|
-
if (
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
const { ChatCompletionsAdapter } = await import('./chat-completions.js');
|
|
264
|
-
return new ChatCompletionsAdapter({
|
|
265
|
-
apiKey: 'proxy', // CopilotProxy handles auth
|
|
266
|
-
baseUrl: `${config.proxyUrl}/v1`,
|
|
267
|
-
});
|
|
254
|
+
if (
|
|
255
|
+
adapter === 'chat-completions' ||
|
|
256
|
+
adapter === 'openai' ||
|
|
257
|
+
adapter === 'proxy' ||
|
|
258
|
+
(!adapter && (config.openaiApiKey || config.proxyUrl))
|
|
259
|
+
) {
|
|
260
|
+
throw new Error(
|
|
261
|
+
'The chat-completions adapter was removed in Phase 7. ' +
|
|
262
|
+
'Configure providers via ~/.yeaft/config.json with protocol: "anthropic" ' +
|
|
263
|
+
'or protocol: "openai-responses" instead of using the legacy ' +
|
|
264
|
+
'"openai"/"proxy"/"chat-completions" adapter env-var path.'
|
|
265
|
+
);
|
|
268
266
|
}
|
|
269
267
|
|
|
270
268
|
throw new Error(
|
|
271
|
-
'No LLM adapter configured. Set YEAFT_API_KEY (Anthropic)
|
|
269
|
+
'No LLM adapter configured. Set YEAFT_API_KEY (Anthropic) or configure ' +
|
|
270
|
+
'providers in ~/.yeaft/config.json. The chat-completions/openai/proxy ' +
|
|
271
|
+
'env-var paths were removed in Phase 7 — use protocol: "openai-responses" ' +
|
|
272
|
+
'in a provider entry instead.',
|
|
272
273
|
);
|
|
273
274
|
}
|
package/unify/llm/router.js
CHANGED
|
@@ -5,11 +5,14 @@
|
|
|
5
5
|
* [{ name, baseUrl, apiKey, protocol?, models[] }, ...]
|
|
6
6
|
*
|
|
7
7
|
* The router resolves model → provider, lazy-creates the right adapter
|
|
8
|
-
* (AnthropicAdapter or
|
|
8
|
+
* (AnthropicAdapter or OpenAIResponsesAdapter based on protocol), caches it,
|
|
9
9
|
* and forwards stream()/call() to the resolved adapter.
|
|
10
10
|
*
|
|
11
|
-
* protocol
|
|
12
|
-
*
|
|
11
|
+
* protocol must be one of:
|
|
12
|
+
* - "anthropic" — Anthropic Messages API (required for claude-* models)
|
|
13
|
+
* - "openai-responses" — OpenAI Responses API (default for everything else)
|
|
14
|
+
*
|
|
15
|
+
* Phase 7 removed the legacy "openai" (Chat Completions) protocol entirely.
|
|
13
16
|
*/
|
|
14
17
|
|
|
15
18
|
import { LLMAdapter } from './adapter.js';
|
|
@@ -93,23 +96,25 @@ export class AdapterRouter extends LLMAdapter {
|
|
|
93
96
|
/**
|
|
94
97
|
* Resolve the effective wire protocol for a (provider, model) pair.
|
|
95
98
|
*
|
|
96
|
-
*
|
|
97
|
-
* model IDs
|
|
98
|
-
*
|
|
99
|
-
* that mismatch and downgrade to chat-completions, which proxies like
|
|
100
|
-
* GitHub Copilot DO support for Claude models.
|
|
99
|
+
* Phase 7: only "anthropic" and "openai-responses" are supported. Claude
|
|
100
|
+
* model IDs require provider.protocol === "anthropic" — there is no
|
|
101
|
+
* chat-completions fallback any more.
|
|
101
102
|
*
|
|
102
103
|
* @param {object} provider — Provider config
|
|
103
104
|
* @param {string} modelId
|
|
104
|
-
* @returns {'anthropic' | 'openai-responses'
|
|
105
|
+
* @returns {'anthropic' | 'openai-responses'}
|
|
105
106
|
*/
|
|
106
107
|
#effectiveProtocol(provider, modelId) {
|
|
107
|
-
const declared = provider.protocol || 'openai';
|
|
108
|
-
// Anthropic models — only 'anthropic' or 'openai' (chat-completions) make sense.
|
|
109
|
-
// Responses API does not support Claude → fall back to chat-completions.
|
|
108
|
+
const declared = provider.protocol || 'openai-responses';
|
|
110
109
|
if (typeof modelId === 'string' && modelId.startsWith('claude-')) {
|
|
111
|
-
if (declared
|
|
112
|
-
|
|
110
|
+
if (declared !== 'anthropic') {
|
|
111
|
+
throw new Error(
|
|
112
|
+
`Claude models require provider.protocol="anthropic"; ` +
|
|
113
|
+
`chat-completions fallback removed in Phase 7. ` +
|
|
114
|
+
`Provider "${provider.name}" declares protocol="${declared}" for model "${modelId}".`
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
return 'anthropic';
|
|
113
118
|
}
|
|
114
119
|
return declared;
|
|
115
120
|
}
|
|
@@ -131,8 +136,8 @@ export class AdapterRouter extends LLMAdapter {
|
|
|
131
136
|
}
|
|
132
137
|
|
|
133
138
|
// Compute the effective protocol per model — a single provider may need
|
|
134
|
-
// two adapters (e.g.
|
|
135
|
-
//
|
|
139
|
+
// two adapters (e.g. mixed config: openai-responses for gpt-5*, anthropic
|
|
140
|
+
// for claude-*). Cache key includes the protocol.
|
|
136
141
|
const protocol = this.#effectiveProtocol(provider, modelId);
|
|
137
142
|
const cacheKey = `${provider.name}::${protocol}`;
|
|
138
143
|
const cached = this.#adapterCache.get(cacheKey);
|
|
@@ -147,19 +152,18 @@ export class AdapterRouter extends LLMAdapter {
|
|
|
147
152
|
baseUrl: provider.baseUrl,
|
|
148
153
|
});
|
|
149
154
|
} else if (protocol === 'openai-responses') {
|
|
150
|
-
// OpenAI Responses API (/v1/responses) —
|
|
155
|
+
// OpenAI Responses API (/v1/responses) — canonical OpenAI-compatible path.
|
|
151
156
|
const { OpenAIResponsesAdapter } = await import('./openai-responses.js');
|
|
152
157
|
adapter = new OpenAIResponsesAdapter({
|
|
153
158
|
apiKey: provider.apiKey,
|
|
154
159
|
baseUrl: provider.baseUrl,
|
|
155
160
|
});
|
|
156
161
|
} else {
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
});
|
|
162
|
+
throw new Error(
|
|
163
|
+
`Unsupported protocol "${protocol}" for provider "${provider.name}". ` +
|
|
164
|
+
`Use protocol: "anthropic" or "openai-responses". ` +
|
|
165
|
+
`The chat-completions adapter was removed in Phase 7.`
|
|
166
|
+
);
|
|
163
167
|
}
|
|
164
168
|
|
|
165
169
|
this.#adapterCache.set(cacheKey, adapter);
|
package/unify/models.js
CHANGED
|
@@ -76,7 +76,7 @@ export const MODEL_REGISTRY = new Map([
|
|
|
76
76
|
// ── OpenAI ─────────────────────────────────────────────────────
|
|
77
77
|
['gpt-5', {
|
|
78
78
|
provider: 'openai',
|
|
79
|
-
adapter: '
|
|
79
|
+
adapter: 'openai-responses',
|
|
80
80
|
baseUrl: 'https://api.openai.com/v1',
|
|
81
81
|
contextWindow: 256000,
|
|
82
82
|
maxOutputTokens: 16384,
|
|
@@ -91,25 +91,25 @@ export const MODEL_REGISTRY = new Map([
|
|
|
91
91
|
// should come from provider config (user-supplied) instead of guesses.
|
|
92
92
|
['gpt-5-mini', {
|
|
93
93
|
provider: 'openai',
|
|
94
|
-
adapter: '
|
|
94
|
+
adapter: 'openai-responses',
|
|
95
95
|
baseUrl: 'https://api.openai.com/v1',
|
|
96
96
|
displayName: 'GPT-5 Mini',
|
|
97
97
|
}],
|
|
98
98
|
['gpt-5-nano', {
|
|
99
99
|
provider: 'openai',
|
|
100
|
-
adapter: '
|
|
100
|
+
adapter: 'openai-responses',
|
|
101
101
|
baseUrl: 'https://api.openai.com/v1',
|
|
102
102
|
displayName: 'GPT-5 Nano',
|
|
103
103
|
}],
|
|
104
104
|
['gpt-5-pro', {
|
|
105
105
|
provider: 'openai',
|
|
106
|
-
adapter: '
|
|
106
|
+
adapter: 'openai-responses',
|
|
107
107
|
baseUrl: 'https://api.openai.com/v1',
|
|
108
108
|
displayName: 'GPT-5 Pro',
|
|
109
109
|
}],
|
|
110
110
|
['gpt-5.4', {
|
|
111
111
|
provider: 'openai',
|
|
112
|
-
adapter: '
|
|
112
|
+
adapter: 'openai-responses',
|
|
113
113
|
baseUrl: 'https://api.openai.com/v1',
|
|
114
114
|
contextWindow: 272000,
|
|
115
115
|
maxOutputTokens: 16384,
|
|
@@ -117,7 +117,7 @@ export const MODEL_REGISTRY = new Map([
|
|
|
117
117
|
}],
|
|
118
118
|
['gpt-4.1', {
|
|
119
119
|
provider: 'openai',
|
|
120
|
-
adapter: '
|
|
120
|
+
adapter: 'openai-responses',
|
|
121
121
|
baseUrl: 'https://api.openai.com/v1',
|
|
122
122
|
contextWindow: 1047576,
|
|
123
123
|
maxOutputTokens: 32768,
|
|
@@ -125,7 +125,7 @@ export const MODEL_REGISTRY = new Map([
|
|
|
125
125
|
}],
|
|
126
126
|
['gpt-4.1-mini', {
|
|
127
127
|
provider: 'openai',
|
|
128
|
-
adapter: '
|
|
128
|
+
adapter: 'openai-responses',
|
|
129
129
|
baseUrl: 'https://api.openai.com/v1',
|
|
130
130
|
contextWindow: 1047576,
|
|
131
131
|
maxOutputTokens: 16384,
|
|
@@ -133,7 +133,7 @@ export const MODEL_REGISTRY = new Map([
|
|
|
133
133
|
}],
|
|
134
134
|
['gpt-4.1-nano', {
|
|
135
135
|
provider: 'openai',
|
|
136
|
-
adapter: '
|
|
136
|
+
adapter: 'openai-responses',
|
|
137
137
|
baseUrl: 'https://api.openai.com/v1',
|
|
138
138
|
contextWindow: 1047576,
|
|
139
139
|
maxOutputTokens: 16384,
|
|
@@ -141,7 +141,7 @@ export const MODEL_REGISTRY = new Map([
|
|
|
141
141
|
}],
|
|
142
142
|
['o3', {
|
|
143
143
|
provider: 'openai',
|
|
144
|
-
adapter: '
|
|
144
|
+
adapter: 'openai-responses',
|
|
145
145
|
baseUrl: 'https://api.openai.com/v1',
|
|
146
146
|
contextWindow: 200000,
|
|
147
147
|
maxOutputTokens: 100000,
|
|
@@ -153,7 +153,7 @@ export const MODEL_REGISTRY = new Map([
|
|
|
153
153
|
}],
|
|
154
154
|
['o4-mini', {
|
|
155
155
|
provider: 'openai',
|
|
156
|
-
adapter: '
|
|
156
|
+
adapter: 'openai-responses',
|
|
157
157
|
baseUrl: 'https://api.openai.com/v1',
|
|
158
158
|
contextWindow: 200000,
|
|
159
159
|
maxOutputTokens: 100000,
|
|
@@ -166,7 +166,7 @@ export const MODEL_REGISTRY = new Map([
|
|
|
166
166
|
// ── DeepSeek ───────────────────────────────────────────────────
|
|
167
167
|
['deepseek-chat', {
|
|
168
168
|
provider: 'deepseek',
|
|
169
|
-
adapter: '
|
|
169
|
+
adapter: 'openai-responses',
|
|
170
170
|
baseUrl: 'https://api.deepseek.com',
|
|
171
171
|
contextWindow: 131072,
|
|
172
172
|
maxOutputTokens: 8192,
|
|
@@ -174,7 +174,7 @@ export const MODEL_REGISTRY = new Map([
|
|
|
174
174
|
}],
|
|
175
175
|
['deepseek-reasoner', {
|
|
176
176
|
provider: 'deepseek',
|
|
177
|
-
adapter: '
|
|
177
|
+
adapter: 'openai-responses',
|
|
178
178
|
baseUrl: 'https://api.deepseek.com',
|
|
179
179
|
contextWindow: 131072,
|
|
180
180
|
maxOutputTokens: 8192,
|
|
@@ -184,7 +184,7 @@ export const MODEL_REGISTRY = new Map([
|
|
|
184
184
|
// ── Google (via OpenAI-compatible API) ─────────────────────────
|
|
185
185
|
['gemini-2.5-pro', {
|
|
186
186
|
provider: 'google',
|
|
187
|
-
adapter: '
|
|
187
|
+
adapter: 'openai-responses',
|
|
188
188
|
baseUrl: 'https://generativelanguage.googleapis.com/v1beta/openai',
|
|
189
189
|
contextWindow: 1048576,
|
|
190
190
|
maxOutputTokens: 65536,
|
|
@@ -192,7 +192,7 @@ export const MODEL_REGISTRY = new Map([
|
|
|
192
192
|
}],
|
|
193
193
|
['gemini-2.5-flash', {
|
|
194
194
|
provider: 'google',
|
|
195
|
-
adapter: '
|
|
195
|
+
adapter: 'openai-responses',
|
|
196
196
|
baseUrl: 'https://generativelanguage.googleapis.com/v1beta/openai',
|
|
197
197
|
contextWindow: 1048576,
|
|
198
198
|
maxOutputTokens: 65536,
|
|
@@ -1,501 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* chat-completions.js — OpenAI Chat Completions API adapter
|
|
3
|
-
*
|
|
4
|
-
* Covers ALL OpenAI-compatible backends via baseUrl:
|
|
5
|
-
* - https://api.openai.com/v1 → OpenAI direct
|
|
6
|
-
* - https://api.deepseek.com → DeepSeek
|
|
7
|
-
* - http://localhost:6628/v1 → CopilotProxy
|
|
8
|
-
* - Azure, Ollama, LMStudio, etc.
|
|
9
|
-
*
|
|
10
|
-
* Key translation responsibilities:
|
|
11
|
-
* Request: UnifiedToolDef → { type: "function", function: { name, description, parameters } }
|
|
12
|
-
* Response: delta.tool_calls[i].function.arguments (JSON string) → accumulate → JSON.parse → UnifiedToolCall
|
|
13
|
-
* Result: UnifiedToolResult → { role: "tool", tool_call_id, content }
|
|
14
|
-
* Finish: "tool_calls" → "tool_use", "stop" → "end_turn", "length" → "max_tokens"
|
|
15
|
-
*
|
|
16
|
-
* max_tokens strategy (based on model ID):
|
|
17
|
-
* OpenAI models (gpt-*, o1*, o3*, o4*) use "max_completion_tokens" (new standard).
|
|
18
|
-
* All other models (DeepSeek, Gemini, etc.) use "max_tokens" (legacy/compat).
|
|
19
|
-
* CopilotProxy transparently forwards whatever the client sends.
|
|
20
|
-
* Callers can override via extraBody to pass any parameter directly.
|
|
21
|
-
*/
|
|
22
|
-
|
|
23
|
-
import {
|
|
24
|
-
LLMAdapter,
|
|
25
|
-
LLMRateLimitError,
|
|
26
|
-
LLMAuthError,
|
|
27
|
-
LLMContextError,
|
|
28
|
-
LLMServerError,
|
|
29
|
-
LLMAbortError,
|
|
30
|
-
redactRawRequest,
|
|
31
|
-
capRawRequest,
|
|
32
|
-
capRawString,
|
|
33
|
-
RAW_PAYLOAD_CAP_BYTES,
|
|
34
|
-
} from './adapter.js';
|
|
35
|
-
import {
|
|
36
|
-
normalizeEffort,
|
|
37
|
-
mapEffortToOpenAIReasoning,
|
|
38
|
-
getThinkingCapability,
|
|
39
|
-
} from '../models.js';
|
|
40
|
-
|
|
41
|
-
/**
|
|
42
|
-
* task-327a: feature-flag accessor. Read lazily so tests can flip.
|
|
43
|
-
*/
|
|
44
|
-
function thinkingV1Enabled() {
|
|
45
|
-
return process.env.UNIFY_THINKING_V1 === '1';
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
/**
|
|
49
|
-
* task-DESIGN-v4: Chat Completions adapter is deprecated in favour of
|
|
50
|
-
* `openai-responses.js` (Responses API) for OpenAI-protocol providers and
|
|
51
|
-
* `anthropic.js` for Anthropic. This warning fires once per process the
|
|
52
|
-
* first time the adapter is instantiated, unless UNIFY_SUPPRESS_DEPRECATION=1.
|
|
53
|
-
* Removal is scheduled for Phase 7 of the multi-VP redesign — see
|
|
54
|
-
* `agent/unify/DESIGN.md` § "Migration Plan".
|
|
55
|
-
*/
|
|
56
|
-
let _chatCompletionsDeprecationWarned = false;
|
|
57
|
-
function warnChatCompletionsDeprecated() {
|
|
58
|
-
if (_chatCompletionsDeprecationWarned) return;
|
|
59
|
-
if (process.env.UNIFY_SUPPRESS_DEPRECATION === '1') {
|
|
60
|
-
_chatCompletionsDeprecationWarned = true;
|
|
61
|
-
return;
|
|
62
|
-
}
|
|
63
|
-
_chatCompletionsDeprecationWarned = true;
|
|
64
|
-
// eslint-disable-next-line no-console
|
|
65
|
-
console.warn(
|
|
66
|
-
'[unify] ChatCompletionsAdapter is deprecated. Migrate OpenAI-protocol '
|
|
67
|
-
+ 'providers to the Responses API (set provider.protocol="openai-responses"). '
|
|
68
|
-
+ 'This adapter will be removed in a future release. Set '
|
|
69
|
-
+ 'UNIFY_SUPPRESS_DEPRECATION=1 to silence this warning.'
|
|
70
|
-
);
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
/**
|
|
74
|
-
* Check if a model ID is an OpenAI model that supports max_completion_tokens.
|
|
75
|
-
* OpenAI introduced max_completion_tokens with o1 and made it standard for
|
|
76
|
-
* GPT-4.1+, o-series, and GPT-5+. Other OpenAI-compatible APIs (DeepSeek,
|
|
77
|
-
* Gemini, Ollama) still only understand max_tokens.
|
|
78
|
-
*
|
|
79
|
-
* @param {string} model — The model ID (e.g. "gpt-5", "deepseek-chat", "o3")
|
|
80
|
-
* @returns {boolean} true = use max_completion_tokens
|
|
81
|
-
*/
|
|
82
|
-
export function useNewMaxTokensParam(model) {
|
|
83
|
-
if (!model) return false;
|
|
84
|
-
const m = model.toLowerCase();
|
|
85
|
-
// GPT-4.1+ and GPT-5+
|
|
86
|
-
if (m.startsWith('gpt-')) return true;
|
|
87
|
-
// o-series reasoning models (o1, o3, o4-mini, etc.)
|
|
88
|
-
if (/^o\d/.test(m)) return true;
|
|
89
|
-
// Everything else (deepseek-*, gemini-*, claude-*, custom models): legacy
|
|
90
|
-
return false;
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
/**
|
|
94
|
-
* ChatCompletionsAdapter — Talks to OpenAI Chat Completions API and compatibles.
|
|
95
|
-
*/
|
|
96
|
-
export class ChatCompletionsAdapter extends LLMAdapter {
|
|
97
|
-
#apiKey;
|
|
98
|
-
#baseUrl;
|
|
99
|
-
|
|
100
|
-
/**
|
|
101
|
-
* @param {{ apiKey: string, baseUrl: string }} config
|
|
102
|
-
*/
|
|
103
|
-
constructor({ apiKey, baseUrl }) {
|
|
104
|
-
super({ apiKey, baseUrl });
|
|
105
|
-
this.#apiKey = apiKey;
|
|
106
|
-
this.#baseUrl = baseUrl.replace(/\/+$/, ''); // strip trailing slash
|
|
107
|
-
warnChatCompletionsDeprecated();
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
/** Expose baseUrl for testing. */
|
|
111
|
-
get baseUrl() { return this.#baseUrl; }
|
|
112
|
-
|
|
113
|
-
/**
|
|
114
|
-
* Build the max-tokens portion of the request body.
|
|
115
|
-
* Uses max_completion_tokens for OpenAI models, max_tokens for others.
|
|
116
|
-
*
|
|
117
|
-
* @param {string} model
|
|
118
|
-
* @param {number} maxTokens
|
|
119
|
-
* @returns {object}
|
|
120
|
-
*/
|
|
121
|
-
#maxTokensBody(model, maxTokens) {
|
|
122
|
-
if (useNewMaxTokensParam(model)) {
|
|
123
|
-
return { max_completion_tokens: maxTokens };
|
|
124
|
-
}
|
|
125
|
-
return { max_tokens: maxTokens };
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
/**
|
|
129
|
-
* Translate UnifiedToolDef[] → Chat Completions tool format.
|
|
130
|
-
* @param {import('./adapter.js').UnifiedToolDef[]} tools
|
|
131
|
-
* @returns {object[]|undefined}
|
|
132
|
-
*/
|
|
133
|
-
#translateTools(tools) {
|
|
134
|
-
if (!tools || tools.length === 0) return undefined;
|
|
135
|
-
return tools.map(t => ({
|
|
136
|
-
type: 'function',
|
|
137
|
-
function: {
|
|
138
|
-
name: t.name,
|
|
139
|
-
description: t.description,
|
|
140
|
-
parameters: t.parameters,
|
|
141
|
-
},
|
|
142
|
-
}));
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
/**
|
|
146
|
-
* Translate UnifiedMessage[] → Chat Completions message format.
|
|
147
|
-
* @param {string} system — System prompt
|
|
148
|
-
* @param {import('./adapter.js').UnifiedMessage[]} messages
|
|
149
|
-
* @returns {object[]}
|
|
150
|
-
*/
|
|
151
|
-
#translateMessages(system, messages) {
|
|
152
|
-
const result = [];
|
|
153
|
-
|
|
154
|
-
// System message first
|
|
155
|
-
if (system) {
|
|
156
|
-
result.push({ role: 'system', content: system });
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
for (const msg of messages) {
|
|
160
|
-
if (msg.role === 'system') {
|
|
161
|
-
result.push({ role: 'system', content: msg.content });
|
|
162
|
-
} else if (msg.role === 'user') {
|
|
163
|
-
result.push({ role: 'user', content: msg.content });
|
|
164
|
-
} else if (msg.role === 'assistant') {
|
|
165
|
-
const entry = { role: 'assistant' };
|
|
166
|
-
// Some OpenAI-compatible APIs require `content: null` when tool_calls are present
|
|
167
|
-
entry.content = msg.content || null;
|
|
168
|
-
if (msg.toolCalls && msg.toolCalls.length > 0) {
|
|
169
|
-
entry.tool_calls = msg.toolCalls.map(tc => ({
|
|
170
|
-
id: tc.id,
|
|
171
|
-
type: 'function',
|
|
172
|
-
function: {
|
|
173
|
-
name: tc.name,
|
|
174
|
-
arguments: JSON.stringify(tc.input),
|
|
175
|
-
},
|
|
176
|
-
}));
|
|
177
|
-
}
|
|
178
|
-
result.push(entry);
|
|
179
|
-
} else if (msg.role === 'tool') {
|
|
180
|
-
result.push({
|
|
181
|
-
role: 'tool',
|
|
182
|
-
tool_call_id: msg.toolCallId,
|
|
183
|
-
content: msg.content,
|
|
184
|
-
});
|
|
185
|
-
}
|
|
186
|
-
}
|
|
187
|
-
return result;
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
/**
|
|
191
|
-
* Classify HTTP errors.
|
|
192
|
-
* @param {number} status
|
|
193
|
-
* @param {string} body
|
|
194
|
-
*/
|
|
195
|
-
#classifyError(status, body) {
|
|
196
|
-
if (status === 401 || status === 403) {
|
|
197
|
-
return new LLMAuthError(`Auth error: ${body}`, status);
|
|
198
|
-
}
|
|
199
|
-
if (status === 429) {
|
|
200
|
-
return new LLMRateLimitError(`Rate limit: ${body}`, status);
|
|
201
|
-
}
|
|
202
|
-
if (status === 529) {
|
|
203
|
-
return new LLMRateLimitError(`Overloaded: ${body}`, status);
|
|
204
|
-
}
|
|
205
|
-
if (status === 413 || body.includes('context_length_exceeded') || body.includes('maximum context length')) {
|
|
206
|
-
return new LLMContextError(`Context too long: ${body}`);
|
|
207
|
-
}
|
|
208
|
-
if (status >= 500) {
|
|
209
|
-
return new LLMServerError(`Server error: ${body}`, status);
|
|
210
|
-
}
|
|
211
|
-
return new Error(`API error ${status}: ${body}`);
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
/**
|
|
215
|
-
* Map Chat Completions finish_reason → unified stop reason.
|
|
216
|
-
* @param {string|null} reason
|
|
217
|
-
* @returns {'end_turn' | 'tool_use' | 'max_tokens'}
|
|
218
|
-
*/
|
|
219
|
-
#mapFinishReason(reason) {
|
|
220
|
-
switch (reason) {
|
|
221
|
-
case 'tool_calls': return 'tool_use';
|
|
222
|
-
case 'stop': return 'end_turn';
|
|
223
|
-
case 'length': return 'max_tokens';
|
|
224
|
-
default: return 'end_turn';
|
|
225
|
-
}
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
/**
|
|
229
|
-
* @param {{ model: string, system: string, messages: import('./adapter.js').UnifiedMessage[], tools?: import('./adapter.js').UnifiedToolDef[], maxTokens?: number, effort?: 'low'|'medium'|'high'|'max', extraBody?: object, signal?: AbortSignal }} params
|
|
230
|
-
* @returns {AsyncGenerator<import('./adapter.js').StreamEvent>}
|
|
231
|
-
*/
|
|
232
|
-
async *stream({ model, system, messages, tools, maxTokens = 16384, effort, extraBody, signal, onRawExchange }) {
|
|
233
|
-
if (signal?.aborted) throw new LLMAbortError();
|
|
234
|
-
|
|
235
|
-
const body = {
|
|
236
|
-
model,
|
|
237
|
-
messages: this.#translateMessages(system, messages),
|
|
238
|
-
...this.#maxTokensBody(model, maxTokens),
|
|
239
|
-
stream: true,
|
|
240
|
-
stream_options: { include_usage: true },
|
|
241
|
-
};
|
|
242
|
-
|
|
243
|
-
// task-327a: inject OpenAI reasoning.effort when feature flag on, effort is
|
|
244
|
-
// valid, and model's registry entry flags openai-reasoning protocol.
|
|
245
|
-
// 'max' downgrades to 'high' (OpenAI has no 'max' enum). Unknown / unsupported
|
|
246
|
-
// models silently drop the parameter.
|
|
247
|
-
const normEffort = normalizeEffort(effort);
|
|
248
|
-
if (thinkingV1Enabled() && normEffort) {
|
|
249
|
-
const cap = getThinkingCapability(model);
|
|
250
|
-
if (cap.supportsThinking && cap.thinkingProtocol === 'openai-reasoning') {
|
|
251
|
-
const reasoningEffort = mapEffortToOpenAIReasoning(normEffort);
|
|
252
|
-
if (reasoningEffort) {
|
|
253
|
-
body.reasoning = { effort: reasoningEffort };
|
|
254
|
-
}
|
|
255
|
-
}
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
const translatedTools = this.#translateTools(tools);
|
|
259
|
-
if (translatedTools) body.tools = translatedTools;
|
|
260
|
-
|
|
261
|
-
// extraBody allows callers to pass through any additional/override parameters
|
|
262
|
-
if (extraBody) Object.assign(body, extraBody);
|
|
263
|
-
|
|
264
|
-
const url = `${this.#baseUrl}/chat/completions`;
|
|
265
|
-
const headers = {
|
|
266
|
-
'Content-Type': 'application/json',
|
|
267
|
-
'Authorization': `Bearer ${this.#apiKey}`,
|
|
268
|
-
};
|
|
269
|
-
|
|
270
|
-
// task-344: expose raw request (redacted) for debug panel.
|
|
271
|
-
// task-344 follow-up (N2): cap body size.
|
|
272
|
-
const rawRequest = capRawRequest(redactRawRequest({ url, method: 'POST', headers, body }));
|
|
273
|
-
|
|
274
|
-
const response = await fetch(url, {
|
|
275
|
-
method: 'POST',
|
|
276
|
-
headers,
|
|
277
|
-
body: JSON.stringify(body),
|
|
278
|
-
signal,
|
|
279
|
-
});
|
|
280
|
-
|
|
281
|
-
if (!response.ok) {
|
|
282
|
-
const errorBody = await response.text();
|
|
283
|
-
if (onRawExchange) {
|
|
284
|
-
try {
|
|
285
|
-
onRawExchange({
|
|
286
|
-
rawRequest,
|
|
287
|
-
rawResponse: {
|
|
288
|
-
status: response.status,
|
|
289
|
-
headers: response.headers && typeof response.headers.entries === 'function'
|
|
290
|
-
? Object.fromEntries(response.headers.entries())
|
|
291
|
-
: {},
|
|
292
|
-
// task-344 follow-up (N2): cap error body.
|
|
293
|
-
body: capRawString(errorBody),
|
|
294
|
-
},
|
|
295
|
-
});
|
|
296
|
-
} catch { /* ignore */ }
|
|
297
|
-
}
|
|
298
|
-
throw this.#classifyError(response.status, errorBody);
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
// Parse SSE stream
|
|
302
|
-
const reader = response.body.getReader();
|
|
303
|
-
const decoder = new TextDecoder();
|
|
304
|
-
let buffer = '';
|
|
305
|
-
// task-344: accumulate raw SSE body + headers/status for debug exposure.
|
|
306
|
-
// task-344 follow-up (N2): cap growth at RAW_PAYLOAD_CAP_BYTES.
|
|
307
|
-
let rawSseBody = '';
|
|
308
|
-
let rawSseTotalBytes = 0;
|
|
309
|
-
let rawSseCapped = false;
|
|
310
|
-
const responseHeaders = response.headers && typeof response.headers.entries === 'function'
|
|
311
|
-
? Object.fromEntries(response.headers.entries())
|
|
312
|
-
: {};
|
|
313
|
-
const responseStatus = response.status;
|
|
314
|
-
|
|
315
|
-
// Tool call accumulation — Chat Completions sends tool args as fragments
|
|
316
|
-
// keyed by index within the delta.tool_calls array
|
|
317
|
-
/** @type {Map<number, { id: string, name: string, arguments: string }>} */
|
|
318
|
-
const toolCallAccum = new Map();
|
|
319
|
-
|
|
320
|
-
try {
|
|
321
|
-
while (true) {
|
|
322
|
-
const { done, value } = await reader.read();
|
|
323
|
-
if (done) break;
|
|
324
|
-
|
|
325
|
-
const chunkText = decoder.decode(value, { stream: true });
|
|
326
|
-
buffer += chunkText;
|
|
327
|
-
// task-344 follow-up (N2): size-capped capture.
|
|
328
|
-
rawSseTotalBytes += value.byteLength;
|
|
329
|
-
if (!rawSseCapped) {
|
|
330
|
-
if (rawSseTotalBytes <= RAW_PAYLOAD_CAP_BYTES) {
|
|
331
|
-
rawSseBody += chunkText;
|
|
332
|
-
} else {
|
|
333
|
-
const remaining = RAW_PAYLOAD_CAP_BYTES - (rawSseTotalBytes - value.byteLength);
|
|
334
|
-
if (remaining > 0) {
|
|
335
|
-
rawSseBody += chunkText.slice(0, remaining);
|
|
336
|
-
}
|
|
337
|
-
rawSseCapped = true;
|
|
338
|
-
}
|
|
339
|
-
}
|
|
340
|
-
const lines = buffer.split('\n');
|
|
341
|
-
buffer = lines.pop() || '';
|
|
342
|
-
|
|
343
|
-
for (const line of lines) {
|
|
344
|
-
if (!line.startsWith('data: ')) continue;
|
|
345
|
-
const data = line.slice(6).trim();
|
|
346
|
-
if (data === '[DONE]') continue;
|
|
347
|
-
|
|
348
|
-
let chunk;
|
|
349
|
-
try {
|
|
350
|
-
chunk = JSON.parse(data);
|
|
351
|
-
} catch {
|
|
352
|
-
continue;
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
// Usage (from stream_options: include_usage)
|
|
356
|
-
if (chunk.usage) {
|
|
357
|
-
yield {
|
|
358
|
-
type: 'usage',
|
|
359
|
-
inputTokens: chunk.usage.prompt_tokens || 0,
|
|
360
|
-
outputTokens: chunk.usage.completion_tokens || 0,
|
|
361
|
-
cacheReadTokens: chunk.usage.prompt_tokens_details?.cached_tokens || 0,
|
|
362
|
-
cacheWriteTokens: 0,
|
|
363
|
-
};
|
|
364
|
-
}
|
|
365
|
-
|
|
366
|
-
const choice = chunk.choices?.[0];
|
|
367
|
-
if (!choice) continue;
|
|
368
|
-
|
|
369
|
-
const delta = choice.delta;
|
|
370
|
-
if (!delta) continue;
|
|
371
|
-
|
|
372
|
-
// Text content
|
|
373
|
-
if (delta.content) {
|
|
374
|
-
yield { type: 'text_delta', text: delta.content };
|
|
375
|
-
}
|
|
376
|
-
|
|
377
|
-
// Tool calls (streamed as fragments)
|
|
378
|
-
if (delta.tool_calls) {
|
|
379
|
-
for (const tc of delta.tool_calls) {
|
|
380
|
-
const idx = tc.index;
|
|
381
|
-
if (!toolCallAccum.has(idx)) {
|
|
382
|
-
toolCallAccum.set(idx, {
|
|
383
|
-
id: tc.id || '',
|
|
384
|
-
name: tc.function?.name || '',
|
|
385
|
-
arguments: '',
|
|
386
|
-
});
|
|
387
|
-
}
|
|
388
|
-
const accum = toolCallAccum.get(idx);
|
|
389
|
-
if (tc.id) accum.id = tc.id;
|
|
390
|
-
if (tc.function?.name) accum.name = tc.function.name;
|
|
391
|
-
if (tc.function?.arguments) accum.arguments += tc.function.arguments;
|
|
392
|
-
}
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
// Finish reason
|
|
396
|
-
if (choice.finish_reason) {
|
|
397
|
-
// Emit accumulated tool calls before stop
|
|
398
|
-
for (const [, accum] of toolCallAccum) {
|
|
399
|
-
let parsedInput = {};
|
|
400
|
-
try {
|
|
401
|
-
parsedInput = accum.arguments ? JSON.parse(accum.arguments) : {};
|
|
402
|
-
} catch {
|
|
403
|
-
parsedInput = {};
|
|
404
|
-
}
|
|
405
|
-
yield {
|
|
406
|
-
type: 'tool_call',
|
|
407
|
-
id: accum.id,
|
|
408
|
-
name: accum.name,
|
|
409
|
-
input: parsedInput,
|
|
410
|
-
};
|
|
411
|
-
}
|
|
412
|
-
toolCallAccum.clear();
|
|
413
|
-
|
|
414
|
-
yield {
|
|
415
|
-
type: 'stop',
|
|
416
|
-
stopReason: this.#mapFinishReason(choice.finish_reason),
|
|
417
|
-
};
|
|
418
|
-
}
|
|
419
|
-
}
|
|
420
|
-
}
|
|
421
|
-
} finally {
|
|
422
|
-
reader.releaseLock();
|
|
423
|
-
// task-344: emit raw exchange after stream completes.
|
|
424
|
-
if (onRawExchange) {
|
|
425
|
-
try {
|
|
426
|
-
// task-344 follow-up (N2): append truncation marker when capped.
|
|
427
|
-
const finalBody = rawSseCapped
|
|
428
|
-
? `${rawSseBody}…[truncated, original ${rawSseTotalBytes} bytes]`
|
|
429
|
-
: rawSseBody;
|
|
430
|
-
onRawExchange({
|
|
431
|
-
rawRequest,
|
|
432
|
-
rawResponse: {
|
|
433
|
-
status: responseStatus,
|
|
434
|
-
headers: responseHeaders,
|
|
435
|
-
body: finalBody,
|
|
436
|
-
format: 'sse',
|
|
437
|
-
},
|
|
438
|
-
});
|
|
439
|
-
} catch { /* ignore */ }
|
|
440
|
-
}
|
|
441
|
-
}
|
|
442
|
-
}
|
|
443
|
-
|
|
444
|
-
/**
|
|
445
|
-
* Non-streaming call for side queries.
|
|
446
|
-
*
|
|
447
|
-
* task-327c: accepts `effort` for internal scenario-tagged calls
|
|
448
|
-
* (consolidate/dream/recall/light). Feature-flag + capability guards
|
|
449
|
-
* mirror stream() exactly; unsupported models silently drop the param.
|
|
450
|
-
*/
|
|
451
|
-
async call({ model, system, messages, maxTokens = 4096, effort, extraBody, signal }) {
|
|
452
|
-
if (signal?.aborted) throw new LLMAbortError();
|
|
453
|
-
|
|
454
|
-
const body = {
|
|
455
|
-
model,
|
|
456
|
-
messages: this.#translateMessages(system, messages),
|
|
457
|
-
...this.#maxTokensBody(model, maxTokens),
|
|
458
|
-
};
|
|
459
|
-
|
|
460
|
-
// task-327c: mirror stream()'s reasoning.effort injection for side queries.
|
|
461
|
-
const normEffort = normalizeEffort(effort);
|
|
462
|
-
if (thinkingV1Enabled() && normEffort) {
|
|
463
|
-
const cap = getThinkingCapability(model);
|
|
464
|
-
if (cap.supportsThinking && cap.thinkingProtocol === 'openai-reasoning') {
|
|
465
|
-
const reasoningEffort = mapEffortToOpenAIReasoning(normEffort);
|
|
466
|
-
if (reasoningEffort) {
|
|
467
|
-
body.reasoning = { effort: reasoningEffort };
|
|
468
|
-
}
|
|
469
|
-
}
|
|
470
|
-
}
|
|
471
|
-
|
|
472
|
-
// extraBody allows callers to pass through any additional/override parameters
|
|
473
|
-
if (extraBody) Object.assign(body, extraBody);
|
|
474
|
-
|
|
475
|
-
const response = await fetch(`${this.#baseUrl}/chat/completions`, {
|
|
476
|
-
method: 'POST',
|
|
477
|
-
headers: {
|
|
478
|
-
'Content-Type': 'application/json',
|
|
479
|
-
'Authorization': `Bearer ${this.#apiKey}`,
|
|
480
|
-
},
|
|
481
|
-
body: JSON.stringify(body),
|
|
482
|
-
signal,
|
|
483
|
-
});
|
|
484
|
-
|
|
485
|
-
if (!response.ok) {
|
|
486
|
-
const errorBody = await response.text();
|
|
487
|
-
throw this.#classifyError(response.status, errorBody);
|
|
488
|
-
}
|
|
489
|
-
|
|
490
|
-
const result = await response.json();
|
|
491
|
-
const text = result.choices?.[0]?.message?.content || '';
|
|
492
|
-
|
|
493
|
-
return {
|
|
494
|
-
text,
|
|
495
|
-
usage: {
|
|
496
|
-
inputTokens: result.usage?.prompt_tokens || 0,
|
|
497
|
-
outputTokens: result.usage?.completion_tokens || 0,
|
|
498
|
-
},
|
|
499
|
-
};
|
|
500
|
-
}
|
|
501
|
-
}
|