@yeaft/webchat-agent 0.1.837 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "0.1.837",
3
+ "version": "0.1.839",
4
4
  "description": "Remote agent for Yeaft WebChat — connects worker machines to the central server",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -4,13 +4,19 @@
4
4
  * Given a providers array from config.json:
5
5
  * [{ name, baseUrl, apiKey, protocol?, models[] }, ...]
6
6
  *
7
- * The router resolves model provider, lazy-creates the right adapter
8
- * (AnthropicAdapter or OpenAIResponsesAdapter based on protocol), caches it,
9
- * and forwards stream()/call() to the resolved adapter.
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 must be one of:
12
- * - "anthropic" — Anthropic Messages API (required for claude-* models)
13
- * - "openai-responses" OpenAI Responses API (default for everything else)
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>} modelId provider config */
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
- // First provider wins if model appears in multiple providers
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 modelId of provider.models) {
150
- if (!this.#modelToProvider.has(modelId)) {
151
- this.#modelToProvider.set(modelId, provider);
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
- * Phase 7: only "anthropic" and "openai-responses" are supported. Claude
161
- * model IDs require provider.protocol === "anthropic" — there is no
162
- * chat-completions fallback any more.
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} modelId
229
+ * @param {{id: string, protocol?: string}} entry — Normalized model entry
166
230
  * @returns {'anthropic' | 'openai-responses'}
167
231
  */
168
- #effectiveProtocol(provider, modelId) {
169
- const declared = provider.protocol || 'openai-responses';
170
- if (typeof modelId === 'string' && modelId.startsWith('claude-')) {
171
- if (declared !== 'anthropic') {
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 provider.protocol="anthropic"; ` +
251
+ `Claude models require protocol="anthropic"; ` +
174
252
  `chat-completions fallback removed in Phase 7. ` +
175
- `Provider "${provider.name}" declares protocol="${declared}" for model "${modelId}".`
253
+ `Provider "${provider.name}" resolved protocol="${resolved}" for model "${modelId}"${detail}.`
176
254
  );
177
255
  }
178
256
  return 'anthropic';
179
257
  }
180
- return declared;
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 provider = this.#modelToProvider.get(modelId);
191
- if (!provider) {
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, modelId);
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
- return this.#modelToProvider.get(modelId) || null;
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, provider] of this.#modelToProvider) {
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
- if (ctx === undefined && max === undefined) return entry.id;
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