@yeaft/webchat-agent 0.1.463 → 0.1.465
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/config-api.js +12 -1
- package/unify/config.js +14 -9
- package/unify/llm/openai-responses.js +438 -0
- package/unify/llm/router.js +7 -0
- package/unify/models.js +126 -0
package/package.json
CHANGED
package/unify/config-api.js
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
import { existsSync, readFileSync, writeFileSync } from 'fs';
|
|
12
12
|
import { join } from 'path';
|
|
13
13
|
import { DEFAULT_YEAFT_DIR } from './init.js';
|
|
14
|
+
import { normalizeProviderModels, serializeModelForPersistence } from './models.js';
|
|
14
15
|
|
|
15
16
|
/**
|
|
16
17
|
* Read the LLM-relevant portion of config.json.
|
|
@@ -87,7 +88,17 @@ export function updateLlmConfig(update, dir) {
|
|
|
87
88
|
return { error: `Provider "${p.name}" must have at least one model` };
|
|
88
89
|
}
|
|
89
90
|
}
|
|
90
|
-
|
|
91
|
+
// Normalize + re-serialize each provider's models so that:
|
|
92
|
+
// - id-only entries are persisted as plain strings (back-compat)
|
|
93
|
+
// - entries with ctx / maxOutput are persisted as objects
|
|
94
|
+
// - empty / 0 / NaN values get stripped
|
|
95
|
+
existing.providers = update.providers.map(p => {
|
|
96
|
+
const normalized = normalizeProviderModels(p);
|
|
97
|
+
return {
|
|
98
|
+
...p,
|
|
99
|
+
models: normalized.map(serializeModelForPersistence),
|
|
100
|
+
};
|
|
101
|
+
});
|
|
91
102
|
}
|
|
92
103
|
|
|
93
104
|
// Update model selections
|
package/unify/config.js
CHANGED
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
import { existsSync, readFileSync } from 'fs';
|
|
23
23
|
import { join } from 'path';
|
|
24
24
|
import { DEFAULT_YEAFT_DIR } from './init.js';
|
|
25
|
-
import { resolveModel, parseModelRef } from './models.js';
|
|
25
|
+
import { resolveModel, parseModelRef, normalizeProviderModels } from './models.js';
|
|
26
26
|
|
|
27
27
|
/** Default configuration values. */
|
|
28
28
|
const DEFAULTS = {
|
|
@@ -260,19 +260,24 @@ export function loadConfig(overrides = {}) {
|
|
|
260
260
|
adapter: null,
|
|
261
261
|
};
|
|
262
262
|
|
|
263
|
-
// Aggregate all available models from providers
|
|
263
|
+
// Aggregate all available models from providers.
|
|
264
|
+
// Normalizes each provider's `models` into `{ id, contextWindow?, maxOutput? }`
|
|
265
|
+
// so consumers never have to deal with raw string / object ambiguity.
|
|
264
266
|
config.availableModels = [];
|
|
265
267
|
if (providers) {
|
|
266
268
|
for (const p of providers) {
|
|
267
|
-
|
|
268
|
-
for (const m of
|
|
269
|
+
const normalized = normalizeProviderModels(p);
|
|
270
|
+
for (const m of normalized) {
|
|
269
271
|
// Avoid duplicates (first provider wins)
|
|
270
|
-
if (!config.availableModels.some(am => am.id === m)) {
|
|
271
|
-
|
|
272
|
-
id: m,
|
|
272
|
+
if (!config.availableModels.some(am => am.id === m.id)) {
|
|
273
|
+
const entry = {
|
|
274
|
+
id: m.id,
|
|
273
275
|
provider: p.name,
|
|
274
|
-
label: m,
|
|
275
|
-
}
|
|
276
|
+
label: m.id,
|
|
277
|
+
};
|
|
278
|
+
if (m.contextWindow !== undefined) entry.contextWindow = m.contextWindow;
|
|
279
|
+
if (m.maxOutput !== undefined) entry.maxOutput = m.maxOutput;
|
|
280
|
+
config.availableModels.push(entry);
|
|
276
281
|
}
|
|
277
282
|
}
|
|
278
283
|
}
|
|
@@ -0,0 +1,438 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* openai-responses.js — OpenAI Responses API adapter (/v1/responses)
|
|
3
|
+
*
|
|
4
|
+
* Next-gen OpenAI API recommended for GPT-5+. Differences from Chat Completions:
|
|
5
|
+
* - Endpoint: /v1/responses instead of /v1/chat/completions
|
|
6
|
+
* - Input: `input[]` array of typed items (message / function_call / function_call_output)
|
|
7
|
+
* instead of `messages[]` with role/content
|
|
8
|
+
* - System prompt: passed as `instructions` field (not as a message)
|
|
9
|
+
* - Content parts: `input_text` / `input_image` (image_url string) / `output_text`
|
|
10
|
+
* - Tool definitions: flat `{type:"function", name, description, parameters}`
|
|
11
|
+
* (no nested `function` object)
|
|
12
|
+
* - Tool call id: uses `call_id` (separate from the internal item `id`)
|
|
13
|
+
* - Stream: semantic SSE events
|
|
14
|
+
* - response.created
|
|
15
|
+
* - response.output_item.added
|
|
16
|
+
* - response.output_text.delta
|
|
17
|
+
* - response.function_call_arguments.delta / .done
|
|
18
|
+
* - response.completed (contains final response.usage)
|
|
19
|
+
* - response.incomplete (e.g. max_output_tokens)
|
|
20
|
+
* - response.error
|
|
21
|
+
* - Usage: only in terminal completed/incomplete events
|
|
22
|
+
*
|
|
23
|
+
* Id contract (agreed with PM):
|
|
24
|
+
* - Responses `function_call.call_id` ← → internal UnifiedToolCall.id (direct passthrough)
|
|
25
|
+
* - tool_result.toolCallId ← → function_call_output.call_id (direct passthrough)
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import {
|
|
29
|
+
LLMAdapter,
|
|
30
|
+
LLMRateLimitError,
|
|
31
|
+
LLMAuthError,
|
|
32
|
+
LLMContextError,
|
|
33
|
+
LLMServerError,
|
|
34
|
+
LLMAbortError,
|
|
35
|
+
} from './adapter.js';
|
|
36
|
+
|
|
37
|
+
const DEFAULT_BASE_URL = 'https://api.openai.com/v1';
|
|
38
|
+
|
|
39
|
+
export class OpenAIResponsesAdapter extends LLMAdapter {
|
|
40
|
+
#apiKey;
|
|
41
|
+
#baseUrl;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* @param {{ apiKey: string, baseUrl?: string }} config
|
|
45
|
+
*/
|
|
46
|
+
constructor({ apiKey, baseUrl = DEFAULT_BASE_URL }) {
|
|
47
|
+
super({ apiKey, baseUrl });
|
|
48
|
+
this.#apiKey = apiKey;
|
|
49
|
+
this.#baseUrl = (baseUrl || DEFAULT_BASE_URL).replace(/\/+$/, '');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Expose baseUrl for testing. */
|
|
53
|
+
get baseUrl() { return this.#baseUrl; }
|
|
54
|
+
|
|
55
|
+
// ─── Request translation ────────────────────────────────
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Translate unified tool defs → Responses API tool format (flat).
|
|
59
|
+
* @param {import('./adapter.js').UnifiedToolDef[]} tools
|
|
60
|
+
*/
|
|
61
|
+
#translateTools(tools) {
|
|
62
|
+
if (!tools || tools.length === 0) return undefined;
|
|
63
|
+
return tools.map(t => ({
|
|
64
|
+
type: 'function',
|
|
65
|
+
name: t.name,
|
|
66
|
+
description: t.description,
|
|
67
|
+
parameters: t.parameters,
|
|
68
|
+
}));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Translate a user message's content into Responses API content parts.
|
|
73
|
+
* Accepts either a string or an array of parts (text / image).
|
|
74
|
+
*/
|
|
75
|
+
#translateUserContent(content) {
|
|
76
|
+
if (typeof content === 'string') {
|
|
77
|
+
return [{ type: 'input_text', text: content }];
|
|
78
|
+
}
|
|
79
|
+
if (Array.isArray(content)) {
|
|
80
|
+
return content.map(part => {
|
|
81
|
+
if (!part || typeof part !== 'object') {
|
|
82
|
+
return { type: 'input_text', text: String(part ?? '') };
|
|
83
|
+
}
|
|
84
|
+
if (part.type === 'text') {
|
|
85
|
+
return { type: 'input_text', text: part.text || '' };
|
|
86
|
+
}
|
|
87
|
+
if (part.type === 'image') {
|
|
88
|
+
// part.source may be { url } or { data, mediaType } (base64)
|
|
89
|
+
const src = part.source || {};
|
|
90
|
+
let imageUrl;
|
|
91
|
+
if (src.url) {
|
|
92
|
+
imageUrl = src.url;
|
|
93
|
+
} else if (src.data) {
|
|
94
|
+
const mt = src.mediaType || 'image/png';
|
|
95
|
+
imageUrl = `data:${mt};base64,${src.data}`;
|
|
96
|
+
} else {
|
|
97
|
+
imageUrl = '';
|
|
98
|
+
}
|
|
99
|
+
return { type: 'input_image', image_url: imageUrl };
|
|
100
|
+
}
|
|
101
|
+
// Passthrough for already-shaped parts
|
|
102
|
+
if (part.type === 'input_text' || part.type === 'input_image') return part;
|
|
103
|
+
return { type: 'input_text', text: String(part.text || '') };
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
return [{ type: 'input_text', text: String(content ?? '') }];
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Translate UnifiedMessage[] → Responses API `input[]` array.
|
|
111
|
+
*
|
|
112
|
+
* Message → { type:'message', role, content: parts[] }
|
|
113
|
+
* Assistant tool_calls → separate { type:'function_call', call_id, name, arguments } items
|
|
114
|
+
* Tool message → { type:'function_call_output', call_id, output }
|
|
115
|
+
*/
|
|
116
|
+
#translateInput(messages) {
|
|
117
|
+
const input = [];
|
|
118
|
+
for (const msg of messages) {
|
|
119
|
+
if (msg.role === 'system') {
|
|
120
|
+
// System is handled as `instructions` at the top-level; skip here.
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
if (msg.role === 'user') {
|
|
124
|
+
input.push({
|
|
125
|
+
type: 'message',
|
|
126
|
+
role: 'user',
|
|
127
|
+
content: this.#translateUserContent(msg.content),
|
|
128
|
+
});
|
|
129
|
+
} else if (msg.role === 'assistant') {
|
|
130
|
+
// Emit a message item if there is text content
|
|
131
|
+
if (msg.content && typeof msg.content === 'string' && msg.content.trim()) {
|
|
132
|
+
input.push({
|
|
133
|
+
type: 'message',
|
|
134
|
+
role: 'assistant',
|
|
135
|
+
content: [{ type: 'output_text', text: msg.content }],
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
if (msg.toolCalls && msg.toolCalls.length > 0) {
|
|
139
|
+
for (const tc of msg.toolCalls) {
|
|
140
|
+
input.push({
|
|
141
|
+
type: 'function_call',
|
|
142
|
+
call_id: tc.id,
|
|
143
|
+
name: tc.name,
|
|
144
|
+
arguments: JSON.stringify(tc.input ?? {}),
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
} else if (msg.role === 'tool') {
|
|
149
|
+
input.push({
|
|
150
|
+
type: 'function_call_output',
|
|
151
|
+
call_id: msg.toolCallId,
|
|
152
|
+
output: typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content),
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return input;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ─── Error classification ───────────────────────────────
|
|
160
|
+
|
|
161
|
+
#classifyError(status, body) {
|
|
162
|
+
if (status === 401 || status === 403) {
|
|
163
|
+
return new LLMAuthError(`Auth error: ${body}`, status);
|
|
164
|
+
}
|
|
165
|
+
if (status === 429) {
|
|
166
|
+
return new LLMRateLimitError(`Rate limit: ${body}`, status);
|
|
167
|
+
}
|
|
168
|
+
if (status === 529) {
|
|
169
|
+
return new LLMRateLimitError(`Overloaded: ${body}`, status);
|
|
170
|
+
}
|
|
171
|
+
if (status === 413 || body.includes('context_length_exceeded') || body.includes('maximum context length')) {
|
|
172
|
+
return new LLMContextError(`Context too long: ${body}`);
|
|
173
|
+
}
|
|
174
|
+
if (status >= 500) {
|
|
175
|
+
return new LLMServerError(`Server error: ${body}`, status);
|
|
176
|
+
}
|
|
177
|
+
return new Error(`API error ${status}: ${body}`);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ─── Stop reason mapping ────────────────────────────────
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Map a terminal response object to a unified stop reason.
|
|
184
|
+
* @param {object} response — response.completed or response.incomplete payload
|
|
185
|
+
* @param {boolean} sawToolCall
|
|
186
|
+
*/
|
|
187
|
+
#mapStopReason(response, sawToolCall) {
|
|
188
|
+
if (response?.status === 'incomplete') {
|
|
189
|
+
const r = response.incomplete_details?.reason;
|
|
190
|
+
if (r === 'max_output_tokens') return 'max_tokens';
|
|
191
|
+
return 'end_turn';
|
|
192
|
+
}
|
|
193
|
+
if (sawToolCall) return 'tool_use';
|
|
194
|
+
// Inspect the final output: if the last item is a function_call, it's tool_use
|
|
195
|
+
const out = Array.isArray(response?.output) ? response.output : [];
|
|
196
|
+
if (out.some(item => item?.type === 'function_call')) return 'tool_use';
|
|
197
|
+
return 'end_turn';
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// ─── Streaming ──────────────────────────────────────────
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* @param {{ model: string, system: string, messages: import('./adapter.js').UnifiedMessage[], tools?: import('./adapter.js').UnifiedToolDef[], maxTokens?: number, extraBody?: object, signal?: AbortSignal }} params
|
|
204
|
+
*/
|
|
205
|
+
async *stream({ model, system, messages, tools, maxTokens = 16384, extraBody, signal }) {
|
|
206
|
+
if (signal?.aborted) throw new LLMAbortError();
|
|
207
|
+
|
|
208
|
+
const body = {
|
|
209
|
+
model,
|
|
210
|
+
input: this.#translateInput(messages),
|
|
211
|
+
stream: true,
|
|
212
|
+
max_output_tokens: maxTokens,
|
|
213
|
+
};
|
|
214
|
+
if (system) body.instructions = system;
|
|
215
|
+
const translatedTools = this.#translateTools(tools);
|
|
216
|
+
if (translatedTools) body.tools = translatedTools;
|
|
217
|
+
if (extraBody) Object.assign(body, extraBody);
|
|
218
|
+
|
|
219
|
+
let response;
|
|
220
|
+
try {
|
|
221
|
+
response = await fetch(`${this.#baseUrl}/responses`, {
|
|
222
|
+
method: 'POST',
|
|
223
|
+
headers: {
|
|
224
|
+
'Content-Type': 'application/json',
|
|
225
|
+
'Authorization': `Bearer ${this.#apiKey}`,
|
|
226
|
+
},
|
|
227
|
+
body: JSON.stringify(body),
|
|
228
|
+
signal,
|
|
229
|
+
});
|
|
230
|
+
} catch (err) {
|
|
231
|
+
if (err.name === 'AbortError') throw new LLMAbortError();
|
|
232
|
+
throw err;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (!response.ok) {
|
|
236
|
+
const errorBody = await response.text();
|
|
237
|
+
throw this.#classifyError(response.status, errorBody);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const reader = response.body.getReader();
|
|
241
|
+
const decoder = new TextDecoder();
|
|
242
|
+
let buffer = '';
|
|
243
|
+
|
|
244
|
+
/** Accumulate tool call arguments by output_index.
|
|
245
|
+
* Value: { callId, name, arguments } */
|
|
246
|
+
const toolCallAccum = new Map();
|
|
247
|
+
/** call_ids already emitted as tool_call events (to avoid duplicating on completed fallback). */
|
|
248
|
+
const emittedToolCallIds = new Set();
|
|
249
|
+
let sawToolCall = false;
|
|
250
|
+
|
|
251
|
+
try {
|
|
252
|
+
while (true) {
|
|
253
|
+
const { done, value } = await reader.read();
|
|
254
|
+
if (done) break;
|
|
255
|
+
buffer += decoder.decode(value, { stream: true });
|
|
256
|
+
|
|
257
|
+
// SSE events are separated by blank lines; split on \n
|
|
258
|
+
const lines = buffer.split('\n');
|
|
259
|
+
buffer = lines.pop() || '';
|
|
260
|
+
|
|
261
|
+
for (const rawLine of lines) {
|
|
262
|
+
const line = rawLine.trimEnd();
|
|
263
|
+
if (!line.startsWith('data:')) continue;
|
|
264
|
+
const data = line.slice(5).trim();
|
|
265
|
+
if (!data || data === '[DONE]') continue;
|
|
266
|
+
|
|
267
|
+
let event;
|
|
268
|
+
try {
|
|
269
|
+
event = JSON.parse(data);
|
|
270
|
+
} catch {
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const type = event.type;
|
|
275
|
+
|
|
276
|
+
if (type === 'response.output_item.added') {
|
|
277
|
+
const item = event.item;
|
|
278
|
+
const idx = event.output_index;
|
|
279
|
+
if (item?.type === 'function_call') {
|
|
280
|
+
toolCallAccum.set(idx, {
|
|
281
|
+
callId: item.call_id || item.id || '',
|
|
282
|
+
name: item.name || '',
|
|
283
|
+
arguments: '',
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
} else if (type === 'response.output_text.delta') {
|
|
287
|
+
if (typeof event.delta === 'string' && event.delta.length > 0) {
|
|
288
|
+
yield { type: 'text_delta', text: event.delta };
|
|
289
|
+
}
|
|
290
|
+
} else if (type === 'response.function_call_arguments.delta') {
|
|
291
|
+
const idx = event.output_index;
|
|
292
|
+
const accum = toolCallAccum.get(idx);
|
|
293
|
+
if (accum) {
|
|
294
|
+
accum.arguments += event.delta || '';
|
|
295
|
+
}
|
|
296
|
+
} else if (type === 'response.function_call_arguments.done') {
|
|
297
|
+
const idx = event.output_index;
|
|
298
|
+
const accum = toolCallAccum.get(idx);
|
|
299
|
+
if (accum) {
|
|
300
|
+
// Prefer the .done event's authoritative arguments string if present
|
|
301
|
+
const argsStr = typeof event.arguments === 'string' ? event.arguments : accum.arguments;
|
|
302
|
+
let parsed = {};
|
|
303
|
+
try {
|
|
304
|
+
parsed = argsStr ? JSON.parse(argsStr) : {};
|
|
305
|
+
} catch {
|
|
306
|
+
parsed = {};
|
|
307
|
+
}
|
|
308
|
+
if (accum.callId && !emittedToolCallIds.has(accum.callId)) {
|
|
309
|
+
emittedToolCallIds.add(accum.callId);
|
|
310
|
+
sawToolCall = true;
|
|
311
|
+
yield {
|
|
312
|
+
type: 'tool_call',
|
|
313
|
+
id: accum.callId,
|
|
314
|
+
name: accum.name,
|
|
315
|
+
input: parsed,
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
toolCallAccum.delete(idx);
|
|
319
|
+
}
|
|
320
|
+
} else if (type === 'response.completed' || type === 'response.incomplete') {
|
|
321
|
+
const respObj = event.response || {};
|
|
322
|
+
|
|
323
|
+
// Fallback: flush any function_call items in the final output that we
|
|
324
|
+
// didn't see a .done event for (defensive against partial streams).
|
|
325
|
+
const outputArr = Array.isArray(respObj.output) ? respObj.output : [];
|
|
326
|
+
for (const item of outputArr) {
|
|
327
|
+
if (item?.type !== 'function_call') continue;
|
|
328
|
+
const cid = item.call_id || item.id || '';
|
|
329
|
+
if (!cid || emittedToolCallIds.has(cid)) continue;
|
|
330
|
+
let parsed = {};
|
|
331
|
+
try {
|
|
332
|
+
parsed = item.arguments ? JSON.parse(item.arguments) : {};
|
|
333
|
+
} catch {
|
|
334
|
+
parsed = {};
|
|
335
|
+
}
|
|
336
|
+
emittedToolCallIds.add(cid);
|
|
337
|
+
sawToolCall = true;
|
|
338
|
+
yield {
|
|
339
|
+
type: 'tool_call',
|
|
340
|
+
id: cid,
|
|
341
|
+
name: item.name || '',
|
|
342
|
+
input: parsed,
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// Usage
|
|
347
|
+
const usage = respObj.usage || {};
|
|
348
|
+
yield {
|
|
349
|
+
type: 'usage',
|
|
350
|
+
inputTokens: usage.input_tokens || 0,
|
|
351
|
+
outputTokens: usage.output_tokens || 0,
|
|
352
|
+
cacheReadTokens: usage.input_tokens_details?.cached_tokens || 0,
|
|
353
|
+
cacheWriteTokens: 0,
|
|
354
|
+
};
|
|
355
|
+
|
|
356
|
+
yield {
|
|
357
|
+
type: 'stop',
|
|
358
|
+
stopReason: this.#mapStopReason(respObj, sawToolCall),
|
|
359
|
+
};
|
|
360
|
+
} else if (type === 'response.error') {
|
|
361
|
+
// Let the engine decide; emit error event
|
|
362
|
+
const message = event.error?.message || event.message || 'response.error';
|
|
363
|
+
yield { type: 'error', error: new Error(message), retryable: false };
|
|
364
|
+
}
|
|
365
|
+
// Other semantic events (output_item.done, content_part.added, etc.) are ignored.
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
} catch (err) {
|
|
369
|
+
if (err?.name === 'AbortError') throw new LLMAbortError();
|
|
370
|
+
throw err;
|
|
371
|
+
} finally {
|
|
372
|
+
try { reader.releaseLock(); } catch { /* noop */ }
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// ─── Non-streaming call() ───────────────────────────────
|
|
377
|
+
|
|
378
|
+
async call({ model, system, messages, maxTokens = 4096, extraBody, signal }) {
|
|
379
|
+
if (signal?.aborted) throw new LLMAbortError();
|
|
380
|
+
|
|
381
|
+
const body = {
|
|
382
|
+
model,
|
|
383
|
+
input: this.#translateInput(messages),
|
|
384
|
+
max_output_tokens: maxTokens,
|
|
385
|
+
};
|
|
386
|
+
if (system) body.instructions = system;
|
|
387
|
+
if (extraBody) Object.assign(body, extraBody);
|
|
388
|
+
|
|
389
|
+
let response;
|
|
390
|
+
try {
|
|
391
|
+
response = await fetch(`${this.#baseUrl}/responses`, {
|
|
392
|
+
method: 'POST',
|
|
393
|
+
headers: {
|
|
394
|
+
'Content-Type': 'application/json',
|
|
395
|
+
'Authorization': `Bearer ${this.#apiKey}`,
|
|
396
|
+
},
|
|
397
|
+
body: JSON.stringify(body),
|
|
398
|
+
signal,
|
|
399
|
+
});
|
|
400
|
+
} catch (err) {
|
|
401
|
+
if (err.name === 'AbortError') throw new LLMAbortError();
|
|
402
|
+
throw err;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
if (!response.ok) {
|
|
406
|
+
const errorBody = await response.text();
|
|
407
|
+
throw this.#classifyError(response.status, errorBody);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
const result = await response.json();
|
|
411
|
+
|
|
412
|
+
// Prefer the `output_text` convenience field; fall back to output[].content[].text
|
|
413
|
+
let text = '';
|
|
414
|
+
if (typeof result.output_text === 'string' && result.output_text.length > 0) {
|
|
415
|
+
text = result.output_text;
|
|
416
|
+
} else {
|
|
417
|
+
const out = Array.isArray(result.output) ? result.output : [];
|
|
418
|
+
for (const item of out) {
|
|
419
|
+
if (item?.type === 'message' && Array.isArray(item.content)) {
|
|
420
|
+
for (const part of item.content) {
|
|
421
|
+
if (part?.type === 'output_text' && typeof part.text === 'string') {
|
|
422
|
+
text += part.text;
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
const usage = result.usage || {};
|
|
430
|
+
return {
|
|
431
|
+
text,
|
|
432
|
+
usage: {
|
|
433
|
+
inputTokens: usage.input_tokens || 0,
|
|
434
|
+
outputTokens: usage.output_tokens || 0,
|
|
435
|
+
},
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
}
|
package/unify/llm/router.js
CHANGED
|
@@ -79,6 +79,13 @@ export class AdapterRouter extends LLMAdapter {
|
|
|
79
79
|
apiKey: provider.apiKey,
|
|
80
80
|
baseUrl: provider.baseUrl,
|
|
81
81
|
});
|
|
82
|
+
} else if (protocol === 'openai-responses') {
|
|
83
|
+
// OpenAI Responses API (/v1/responses) — next-gen, recommended for GPT-5+
|
|
84
|
+
const { OpenAIResponsesAdapter } = await import('./openai-responses.js');
|
|
85
|
+
adapter = new OpenAIResponsesAdapter({
|
|
86
|
+
apiKey: provider.apiKey,
|
|
87
|
+
baseUrl: provider.baseUrl,
|
|
88
|
+
});
|
|
82
89
|
} else {
|
|
83
90
|
// Default: openai (Chat Completions API) — covers proxy, OpenAI, DeepSeek, Gemini, etc.
|
|
84
91
|
const { ChatCompletionsAdapter } = await import('./chat-completions.js');
|
package/unify/models.js
CHANGED
|
@@ -59,6 +59,27 @@ export const MODEL_REGISTRY = new Map([
|
|
|
59
59
|
maxOutputTokens: 16384,
|
|
60
60
|
displayName: 'GPT-5',
|
|
61
61
|
}],
|
|
62
|
+
// gpt-5-mini/-nano/-pro: keep id + family/protocol metadata so they appear
|
|
63
|
+
// as known models, but do NOT hardcode context/maxOutput — the real limits
|
|
64
|
+
// should come from provider config (user-supplied) instead of guesses.
|
|
65
|
+
['gpt-5-mini', {
|
|
66
|
+
provider: 'openai',
|
|
67
|
+
adapter: 'chat-completions',
|
|
68
|
+
baseUrl: 'https://api.openai.com/v1',
|
|
69
|
+
displayName: 'GPT-5 Mini',
|
|
70
|
+
}],
|
|
71
|
+
['gpt-5-nano', {
|
|
72
|
+
provider: 'openai',
|
|
73
|
+
adapter: 'chat-completions',
|
|
74
|
+
baseUrl: 'https://api.openai.com/v1',
|
|
75
|
+
displayName: 'GPT-5 Nano',
|
|
76
|
+
}],
|
|
77
|
+
['gpt-5-pro', {
|
|
78
|
+
provider: 'openai',
|
|
79
|
+
adapter: 'chat-completions',
|
|
80
|
+
baseUrl: 'https://api.openai.com/v1',
|
|
81
|
+
displayName: 'GPT-5 Pro',
|
|
82
|
+
}],
|
|
62
83
|
['gpt-5.4', {
|
|
63
84
|
provider: 'openai',
|
|
64
85
|
adapter: 'chat-completions',
|
|
@@ -209,3 +230,108 @@ export function parseModelRef(ref) {
|
|
|
209
230
|
modelId: ref.slice(slashIdx + 1),
|
|
210
231
|
};
|
|
211
232
|
}
|
|
233
|
+
|
|
234
|
+
// ─── task-284: config-driven context / maxOutput ────────────────
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Coerce a possibly-stringy numeric value to a positive integer, or
|
|
238
|
+
* `undefined` if it's empty / zero / NaN / negative / non-finite.
|
|
239
|
+
*
|
|
240
|
+
* @param {*} v
|
|
241
|
+
* @returns {number | undefined}
|
|
242
|
+
*/
|
|
243
|
+
function coercePositiveInt(v) {
|
|
244
|
+
if (v === null || v === undefined || v === '') return undefined;
|
|
245
|
+
const n = typeof v === 'number' ? v : Number(v);
|
|
246
|
+
if (!Number.isFinite(n) || n <= 0) return undefined;
|
|
247
|
+
return Math.floor(n);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Normalize a provider's `models` field into an in-memory array of
|
|
252
|
+
* `{ id, contextWindow?, maxOutput? }` objects.
|
|
253
|
+
*
|
|
254
|
+
* Accepts legacy `string[]` and new object form `{id, contextWindow, maxOutput}`.
|
|
255
|
+
* Empty / 0 / NaN / negative context/max values are treated as unset (dropped).
|
|
256
|
+
*
|
|
257
|
+
* @param {{ models?: Array<string | object> } | null | undefined} provider
|
|
258
|
+
* @returns {Array<{ id: string, contextWindow?: number, maxOutput?: number }>}
|
|
259
|
+
*/
|
|
260
|
+
export function normalizeProviderModels(provider) {
|
|
261
|
+
if (!provider || !Array.isArray(provider.models)) return [];
|
|
262
|
+
const out = [];
|
|
263
|
+
for (const entry of provider.models) {
|
|
264
|
+
if (typeof entry === 'string') {
|
|
265
|
+
const id = entry.trim();
|
|
266
|
+
if (id) out.push({ id });
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
if (entry && typeof entry === 'object' && typeof entry.id === 'string' && entry.id.trim()) {
|
|
270
|
+
const norm = { id: entry.id.trim() };
|
|
271
|
+
const ctx = coercePositiveInt(entry.contextWindow);
|
|
272
|
+
const max = coercePositiveInt(entry.maxOutput);
|
|
273
|
+
if (ctx !== undefined) norm.contextWindow = ctx;
|
|
274
|
+
if (max !== undefined) norm.maxOutput = max;
|
|
275
|
+
out.push(norm);
|
|
276
|
+
}
|
|
277
|
+
// silently skip anything else (null / missing id / numbers)
|
|
278
|
+
}
|
|
279
|
+
return out;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Serialize a normalized model entry back for persistence.
|
|
284
|
+
* - id-only → plain string (back-compat with existing configs)
|
|
285
|
+
* - with ctx or max → object with only the fields that are set
|
|
286
|
+
*
|
|
287
|
+
* @param {{ id: string, contextWindow?: number, maxOutput?: number }} entry
|
|
288
|
+
* @returns {string | object}
|
|
289
|
+
*/
|
|
290
|
+
export function serializeModelForPersistence(entry) {
|
|
291
|
+
if (!entry || typeof entry !== 'object') return entry;
|
|
292
|
+
const ctx = coercePositiveInt(entry.contextWindow);
|
|
293
|
+
const max = coercePositiveInt(entry.maxOutput);
|
|
294
|
+
if (ctx === undefined && max === undefined) return entry.id;
|
|
295
|
+
const obj = { id: entry.id };
|
|
296
|
+
if (ctx !== undefined) obj.contextWindow = ctx;
|
|
297
|
+
if (max !== undefined) obj.maxOutput = max;
|
|
298
|
+
return obj;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Resolve model info with per-provider override.
|
|
303
|
+
*
|
|
304
|
+
* Lookup order:
|
|
305
|
+
* 1. provider-config fields (contextWindow / maxOutput) — highest priority
|
|
306
|
+
* 2. MODEL_REGISTRY entry (contextWindow / maxOutputTokens)
|
|
307
|
+
* 3. undefined if neither has any info
|
|
308
|
+
*
|
|
309
|
+
* Returns a normalized shape: `{ id, contextWindow?, maxOutput?, provider?, adapter?, baseUrl?, displayName? }`.
|
|
310
|
+
*
|
|
311
|
+
* @param {string} model
|
|
312
|
+
* @param {{ id?: string, contextWindow?: number, maxOutput?: number } | null} [providerConfig]
|
|
313
|
+
* @returns {object | undefined}
|
|
314
|
+
*/
|
|
315
|
+
export function getModelInfo(model, providerConfig) {
|
|
316
|
+
if (!model) return undefined;
|
|
317
|
+
const reg = MODEL_REGISTRY.get(model);
|
|
318
|
+
const overrideCtx = coercePositiveInt(providerConfig?.contextWindow);
|
|
319
|
+
const overrideMax = coercePositiveInt(providerConfig?.maxOutput);
|
|
320
|
+
|
|
321
|
+
const ctx = overrideCtx ?? reg?.contextWindow;
|
|
322
|
+
const max = overrideMax ?? reg?.maxOutputTokens;
|
|
323
|
+
|
|
324
|
+
// If we have literally no information, return undefined
|
|
325
|
+
if (!reg && ctx === undefined && max === undefined) return undefined;
|
|
326
|
+
|
|
327
|
+
const info = { id: model };
|
|
328
|
+
if (reg) {
|
|
329
|
+
if (reg.provider) info.provider = reg.provider;
|
|
330
|
+
if (reg.adapter) info.adapter = reg.adapter;
|
|
331
|
+
if (reg.baseUrl) info.baseUrl = reg.baseUrl;
|
|
332
|
+
if (reg.displayName) info.displayName = reg.displayName;
|
|
333
|
+
}
|
|
334
|
+
if (ctx !== undefined) info.contextWindow = ctx;
|
|
335
|
+
if (max !== undefined) info.maxOutput = max;
|
|
336
|
+
return info;
|
|
337
|
+
}
|