@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "0.1.596",
3
+ "version": "0.1.597",
4
4
  "description": "Remote agent for Yeaft WebChat — connects worker machines to the central server",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -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-10): Only two adapters needed:
5
- * 1. AnthropicAdapter — Anthropic Messages API
6
- * 2. ChatCompletionsAdapter — OpenAI Chat Completions API (covers GPT, DeepSeek, CopilotProxy, etc.)
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
- // Normalize adapter name — accept 'chat-completions' as alias for 'openai'
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 (adapter === 'openai' || (!adapter && config.openaiApiKey)) {
252
- if (!config.openaiApiKey && !config.apiKey) {
253
- throw new Error('OpenAI adapter requires YEAFT_OPENAI_API_KEY (or YEAFT_API_KEY as fallback)');
254
- }
255
- const { ChatCompletionsAdapter } = await import('./chat-completions.js');
256
- return new ChatCompletionsAdapter({
257
- apiKey: config.openaiApiKey || config.apiKey,
258
- baseUrl: config.baseUrl || 'https://api.openai.com/v1',
259
- });
260
- }
261
-
262
- if (adapter === 'proxy' || (!adapter && config.proxyUrl)) {
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), YEAFT_OPENAI_API_KEY (OpenAI), or YEAFT_PROXY_URL (CopilotProxy).',
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
  }
@@ -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 ChatCompletionsAdapter based on protocol), caches it,
8
+ * (AnthropicAdapter or OpenAIResponsesAdapter based on protocol), caches it,
9
9
  * and forwards stream()/call() to the resolved adapter.
10
10
  *
11
- * protocol defaults to "openai" (Chat Completions API).
12
- * Set protocol: "anthropic" only for direct Anthropic API connections.
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
- * The provider's declared protocol is the default, but Anthropic-style
97
- * model IDs (e.g. "claude-opus-4.7") cannot be served by the OpenAI
98
- * Responses API even when the provider sits in front of both. Detect
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' | 'openai'}
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 === 'openai-responses') return 'openai';
112
- return declared; // anthropic / openai both fine
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. copilot proxy: openai-responses for gpt-5*,
135
- // chat-completions for claude-*). Cache key includes the protocol.
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) — next-gen, recommended for GPT-5+
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
- // Default: openai (Chat Completions API) — covers proxy, OpenAI, DeepSeek, Gemini, etc.
158
- const { ChatCompletionsAdapter } = await import('./chat-completions.js');
159
- adapter = new ChatCompletionsAdapter({
160
- apiKey: provider.apiKey,
161
- baseUrl: provider.baseUrl,
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: 'chat-completions',
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: 'chat-completions',
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: 'chat-completions',
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: 'chat-completions',
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: 'chat-completions',
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: 'chat-completions',
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: 'chat-completions',
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: 'chat-completions',
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: 'chat-completions',
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: 'chat-completions',
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: 'chat-completions',
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: 'chat-completions',
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: 'chat-completions',
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: 'chat-completions',
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
- }