@yeaft/webchat-agent 0.1.838 → 0.1.839
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/router.js +110 -30
- package/unify/models.js +8 -1
package/package.json
CHANGED
package/unify/llm/router.js
CHANGED
|
@@ -4,13 +4,19 @@
|
|
|
4
4
|
* Given a providers array from config.json:
|
|
5
5
|
* [{ name, baseUrl, apiKey, protocol?, models[] }, ...]
|
|
6
6
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
7
|
+
* `models[]` accepts two shapes (mixable in the same provider):
|
|
8
|
+
* - bare string id: "gpt-5" ← legacy, still supported
|
|
9
|
+
* - object: { id: "gpt-5", protocol?: "..." } ← per-model override
|
|
10
10
|
*
|
|
11
|
-
* protocol
|
|
12
|
-
* -
|
|
13
|
-
*
|
|
11
|
+
* Effective protocol for each (provider, model) is resolved in this order:
|
|
12
|
+
* 1. per-model `protocol` override on the model entry
|
|
13
|
+
* 2. provider-level `protocol` (explicit config wins over inference)
|
|
14
|
+
* 3. heuristic by model id (claude-* → anthropic, gpt-/o1-/o3-/o4-/chatgpt-* → openai-responses)
|
|
15
|
+
* 4. default `openai-responses`
|
|
16
|
+
*
|
|
17
|
+
* This lets a single provider (e.g. GitHub Copilot, a unified proxy) serve
|
|
18
|
+
* both Anthropic and OpenAI families without splitting into two provider
|
|
19
|
+
* entries.
|
|
14
20
|
*
|
|
15
21
|
* Phase 7 removed the legacy "openai" (Chat Completions) protocol entirely.
|
|
16
22
|
*/
|
|
@@ -19,6 +25,55 @@ import { LLMAdapter } from './adapter.js';
|
|
|
19
25
|
import { getThinkingCapability, normalizeEffort } from '../models.js';
|
|
20
26
|
import { pairSanitize } from '../pair-sanitize.js';
|
|
21
27
|
|
|
28
|
+
/**
|
|
29
|
+
* Normalize a model entry to its `{id, protocol?}` object form. Accepts
|
|
30
|
+
* either a bare string or an object so legacy `models: ["gpt-5"]` configs
|
|
31
|
+
* keep working unchanged.
|
|
32
|
+
*
|
|
33
|
+
* @param {string|object} entry
|
|
34
|
+
* @returns {{id: string, protocol?: string} | null}
|
|
35
|
+
*/
|
|
36
|
+
export function normalizeModelEntry(entry) {
|
|
37
|
+
if (typeof entry === 'string') {
|
|
38
|
+
return entry ? { id: entry } : null;
|
|
39
|
+
}
|
|
40
|
+
if (entry && typeof entry === 'object' && typeof entry.id === 'string' && entry.id) {
|
|
41
|
+
const out = { id: entry.id };
|
|
42
|
+
if (typeof entry.protocol === 'string' && entry.protocol) {
|
|
43
|
+
out.protocol = entry.protocol;
|
|
44
|
+
}
|
|
45
|
+
return out;
|
|
46
|
+
}
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Infer the wire protocol from a model id when neither the model entry
|
|
52
|
+
* nor the provider declared one. Centralized so the LlmTab preview and
|
|
53
|
+
* the router agree on the same rule.
|
|
54
|
+
*
|
|
55
|
+
* Returns null when the id doesn't match a known family — the caller
|
|
56
|
+
* falls back to the provider-level protocol (or the global default).
|
|
57
|
+
*/
|
|
58
|
+
export function inferProtocolFromModelId(modelId) {
|
|
59
|
+
if (typeof modelId !== 'string' || !modelId) return null;
|
|
60
|
+
const id = modelId.toLowerCase();
|
|
61
|
+
// Anthropic family: claude-*, claude (bare), or anything starting with
|
|
62
|
+
// "claude" so vendor-prefixed ids like "anthropic.claude-..." also match.
|
|
63
|
+
if (id.startsWith('claude') || id.includes('/claude') || id.includes('.claude')) {
|
|
64
|
+
return 'anthropic';
|
|
65
|
+
}
|
|
66
|
+
// OpenAI Responses-API family. Models that route through /v1/responses:
|
|
67
|
+
// gpt-*, o1*, o3*, o4*, chatgpt-*, codex-*, omni-*.
|
|
68
|
+
// Note: Chat-Completions-only models are intentionally NOT matched here —
|
|
69
|
+
// they fall through to the provider-level protocol and the router will
|
|
70
|
+
// refuse if that doesn't resolve to a supported value.
|
|
71
|
+
if (/^(gpt-|o1|o3|o4|chatgpt-|codex-|omni-)/.test(id)) {
|
|
72
|
+
return 'openai-responses';
|
|
73
|
+
}
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
|
|
22
77
|
/**
|
|
23
78
|
* task-327a: feature-flag accessor. Read lazily so tests can flip.
|
|
24
79
|
*/
|
|
@@ -123,10 +178,10 @@ function sliceUnchanged(original, cleaned) {
|
|
|
123
178
|
* AdapterRouter — Implements LLMAdapter, routes by model → provider.
|
|
124
179
|
*/
|
|
125
180
|
export class AdapterRouter extends LLMAdapter {
|
|
126
|
-
/** @type {Map<string, object
|
|
181
|
+
/** @type {Map<string, {provider: object, entry: {id: string, protocol?: string}}>} */
|
|
127
182
|
#modelToProvider;
|
|
128
183
|
|
|
129
|
-
/** @type {Map<string, LLMAdapter>} providerName → cached adapter */
|
|
184
|
+
/** @type {Map<string, LLMAdapter>} providerName::protocol → cached adapter */
|
|
130
185
|
#adapterCache;
|
|
131
186
|
|
|
132
187
|
/** @type {object[]} raw providers array */
|
|
@@ -142,13 +197,17 @@ export class AdapterRouter extends LLMAdapter {
|
|
|
142
197
|
this.#modelToProvider = new Map();
|
|
143
198
|
this.#adapterCache = new Map();
|
|
144
199
|
|
|
145
|
-
// Build model → provider index
|
|
146
|
-
//
|
|
200
|
+
// Build model id → { provider, entry } index. First provider wins if a
|
|
201
|
+
// model id appears in multiple providers. Each model entry may declare
|
|
202
|
+
// its own `protocol`; we keep the normalized entry so #effectiveProtocol
|
|
203
|
+
// can consult it later without re-parsing.
|
|
147
204
|
for (const provider of providers) {
|
|
148
205
|
if (!Array.isArray(provider.models)) continue;
|
|
149
|
-
for (const
|
|
150
|
-
|
|
151
|
-
|
|
206
|
+
for (const raw of provider.models) {
|
|
207
|
+
const entry = normalizeModelEntry(raw);
|
|
208
|
+
if (!entry) continue;
|
|
209
|
+
if (!this.#modelToProvider.has(entry.id)) {
|
|
210
|
+
this.#modelToProvider.set(entry.id, { provider, entry });
|
|
152
211
|
}
|
|
153
212
|
}
|
|
154
213
|
}
|
|
@@ -157,27 +216,46 @@ export class AdapterRouter extends LLMAdapter {
|
|
|
157
216
|
/**
|
|
158
217
|
* Resolve the effective wire protocol for a (provider, model) pair.
|
|
159
218
|
*
|
|
160
|
-
*
|
|
161
|
-
* model
|
|
162
|
-
*
|
|
219
|
+
* Resolution order:
|
|
220
|
+
* 1. Per-model entry override (provider.models[i].protocol)
|
|
221
|
+
* 2. Provider-level protocol (explicit config wins over inference)
|
|
222
|
+
* 3. Heuristic from model id (claude-* → anthropic, gpt-* → openai-responses)
|
|
223
|
+
* 4. Default "openai-responses"
|
|
224
|
+
*
|
|
225
|
+
* Claude model ids without an anthropic-compatible resolution still throw
|
|
226
|
+
* — chat-completions fallback was removed in Phase 7.
|
|
163
227
|
*
|
|
164
228
|
* @param {object} provider — Provider config
|
|
165
|
-
* @param {string}
|
|
229
|
+
* @param {{id: string, protocol?: string}} entry — Normalized model entry
|
|
166
230
|
* @returns {'anthropic' | 'openai-responses'}
|
|
167
231
|
*/
|
|
168
|
-
#effectiveProtocol(provider,
|
|
169
|
-
const
|
|
170
|
-
|
|
171
|
-
|
|
232
|
+
#effectiveProtocol(provider, entry) {
|
|
233
|
+
const modelId = entry.id;
|
|
234
|
+
const perModel = entry.protocol;
|
|
235
|
+
const inferred = inferProtocolFromModelId(modelId);
|
|
236
|
+
const providerLevel = provider.protocol;
|
|
237
|
+
const resolved = perModel || providerLevel || inferred || 'openai-responses';
|
|
238
|
+
|
|
239
|
+
// Use the SAME predicate as inferProtocolFromModelId so the guard never
|
|
240
|
+
// disagrees with the inference (e.g. "my-claude-proxy" → infer=null →
|
|
241
|
+
// guard wouldn't fire either; "claude-opus-*" → infer=anthropic →
|
|
242
|
+
// guard enforces anthropic). Prevents confusing "resolved openai-responses
|
|
243
|
+
// for claude-*" errors on ids the heuristic didn't actually match.
|
|
244
|
+
if (inferred === 'anthropic') {
|
|
245
|
+
if (resolved !== 'anthropic') {
|
|
246
|
+
const parts = [];
|
|
247
|
+
if (perModel) parts.push(`per-model="${perModel}"`);
|
|
248
|
+
if (providerLevel) parts.push(`provider-level="${providerLevel}"`);
|
|
249
|
+
const detail = parts.length ? ` (${parts.join(', ')})` : '';
|
|
172
250
|
throw new Error(
|
|
173
|
-
`Claude models require
|
|
251
|
+
`Claude models require protocol="anthropic"; ` +
|
|
174
252
|
`chat-completions fallback removed in Phase 7. ` +
|
|
175
|
-
`Provider "${provider.name}"
|
|
253
|
+
`Provider "${provider.name}" resolved protocol="${resolved}" for model "${modelId}"${detail}.`
|
|
176
254
|
);
|
|
177
255
|
}
|
|
178
256
|
return 'anthropic';
|
|
179
257
|
}
|
|
180
|
-
return
|
|
258
|
+
return resolved;
|
|
181
259
|
}
|
|
182
260
|
|
|
183
261
|
/**
|
|
@@ -187,19 +265,20 @@ export class AdapterRouter extends LLMAdapter {
|
|
|
187
265
|
* @returns {Promise<LLMAdapter>}
|
|
188
266
|
*/
|
|
189
267
|
async #resolveAdapter(modelId) {
|
|
190
|
-
const
|
|
191
|
-
if (!
|
|
268
|
+
const hit = this.#modelToProvider.get(modelId);
|
|
269
|
+
if (!hit) {
|
|
192
270
|
throw new Error(
|
|
193
271
|
`Model "${modelId}" not found in any provider. ` +
|
|
194
272
|
`Available models: ${[...this.#modelToProvider.keys()].join(', ') || '(none)'}. ` +
|
|
195
273
|
`Check your config.json providers[].models arrays.`
|
|
196
274
|
);
|
|
197
275
|
}
|
|
276
|
+
const { provider, entry } = hit;
|
|
198
277
|
|
|
199
278
|
// Compute the effective protocol per model — a single provider may need
|
|
200
279
|
// two adapters (e.g. mixed config: openai-responses for gpt-5*, anthropic
|
|
201
280
|
// for claude-*). Cache key includes the protocol.
|
|
202
|
-
const protocol = this.#effectiveProtocol(provider,
|
|
281
|
+
const protocol = this.#effectiveProtocol(provider, entry);
|
|
203
282
|
const cacheKey = `${provider.name}::${protocol}`;
|
|
204
283
|
const cached = this.#adapterCache.get(cacheKey);
|
|
205
284
|
if (cached) return cached;
|
|
@@ -264,7 +343,8 @@ export class AdapterRouter extends LLMAdapter {
|
|
|
264
343
|
* @returns {object|null} — Provider config or null
|
|
265
344
|
*/
|
|
266
345
|
getProviderForModel(modelId) {
|
|
267
|
-
|
|
346
|
+
const hit = this.#modelToProvider.get(modelId);
|
|
347
|
+
return hit ? hit.provider : null;
|
|
268
348
|
}
|
|
269
349
|
|
|
270
350
|
/**
|
|
@@ -274,8 +354,8 @@ export class AdapterRouter extends LLMAdapter {
|
|
|
274
354
|
*/
|
|
275
355
|
listAvailableModels() {
|
|
276
356
|
const result = [];
|
|
277
|
-
for (const [modelId,
|
|
278
|
-
result.push({ modelId, providerName: provider.name });
|
|
357
|
+
for (const [modelId, hit] of this.#modelToProvider) {
|
|
358
|
+
result.push({ modelId, providerName: hit.provider.name });
|
|
279
359
|
}
|
|
280
360
|
return result;
|
|
281
361
|
}
|
package/unify/models.js
CHANGED
|
@@ -446,6 +446,9 @@ export function normalizeProviderModels(provider) {
|
|
|
446
446
|
const max = coercePositiveInt(entry.maxOutput);
|
|
447
447
|
if (ctx !== undefined) norm.contextWindow = ctx;
|
|
448
448
|
if (max !== undefined) norm.maxOutput = max;
|
|
449
|
+
if (typeof entry.protocol === 'string' && entry.protocol.trim()) {
|
|
450
|
+
norm.protocol = entry.protocol.trim();
|
|
451
|
+
}
|
|
449
452
|
out.push(norm);
|
|
450
453
|
}
|
|
451
454
|
// silently skip anything else (null / missing id / numbers)
|
|
@@ -465,10 +468,14 @@ export function serializeModelForPersistence(entry) {
|
|
|
465
468
|
if (!entry || typeof entry !== 'object') return entry;
|
|
466
469
|
const ctx = coercePositiveInt(entry.contextWindow);
|
|
467
470
|
const max = coercePositiveInt(entry.maxOutput);
|
|
468
|
-
|
|
471
|
+
const proto = typeof entry.protocol === 'string' && entry.protocol.trim()
|
|
472
|
+
? entry.protocol.trim()
|
|
473
|
+
: undefined;
|
|
474
|
+
if (ctx === undefined && max === undefined && proto === undefined) return entry.id;
|
|
469
475
|
const obj = { id: entry.id };
|
|
470
476
|
if (ctx !== undefined) obj.contextWindow = ctx;
|
|
471
477
|
if (max !== undefined) obj.maxOutput = max;
|
|
478
|
+
if (proto !== undefined) obj.protocol = proto;
|
|
472
479
|
return obj;
|
|
473
480
|
}
|
|
474
481
|
|