faces-cli 1.6.18 → 1.7.0

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.
@@ -6,6 +6,7 @@ import { FacesAPIError } from '../../client.js';
6
6
  import { loadConfig } from '../../config.js';
7
7
  import { CatalogService, CATALOG_DIR } from '../../catalog.js';
8
8
  import { TeamCatalogService } from '../../team-catalog.js';
9
+ import { resolveEndpoint, MESSAGES_ENDPOINT, RESPONSES_ENDPOINT } from '../../routing.js';
9
10
  export default class CatalogDoctor extends BaseCommand {
10
11
  static description = 'Diagnose and repair the local face and team catalog';
11
12
  static flags = {
@@ -203,6 +204,8 @@ export default class CatalogDoctor extends BaseCommand {
203
204
  const cfg = loadConfig();
204
205
  const catalogModel = cfg.catalog_model ?? 'gpt-5-nano';
205
206
  this.log(`Generating descriptions for ${noDescription.length} face(s) via ${catalogModel}...`);
207
+ // Route the description LLM the same way chat does — off the catalog.
208
+ const { endpoint: descEndpoint } = await resolveEndpoint(client, `_@${catalogModel}`);
206
209
  let generated = 0;
207
210
  for (const alias of noDescription) {
208
211
  const remote = remoteByAlias.get(alias);
@@ -211,14 +214,26 @@ export default class CatalogDoctor extends BaseCommand {
211
214
  try {
212
215
  const prompt = 'Describe yourself in one paragraph. Who are you, what do you care about, and what kind of questions are you best suited to answer?';
213
216
  const faceModel = `${alias}@${catalogModel}`;
214
- const isAnthropic = catalogModel.startsWith('claude');
215
217
  let text;
216
- if (isAnthropic) {
218
+ if (descEndpoint === MESSAGES_ENDPOINT) {
217
219
  const resp = await client.post('/v1/messages', {
218
220
  body: { model: faceModel, messages: [{ role: 'user', content: prompt }], max_tokens: 256 },
219
221
  });
220
222
  text = resp.content?.find((b) => b.type === 'text')?.text;
221
223
  }
224
+ else if (descEndpoint === RESPONSES_ENDPOINT) {
225
+ const resp = await client.post('/v1/responses', {
226
+ body: { model: faceModel, input: [{ role: 'user', content: prompt }], max_output_tokens: 256 },
227
+ });
228
+ let out = '';
229
+ for (const item of resp.output ?? []) {
230
+ for (const block of item.content ?? []) {
231
+ if (block.type === 'output_text')
232
+ out += block.text ?? '';
233
+ }
234
+ }
235
+ text = out || undefined;
236
+ }
222
237
  else {
223
238
  const resp = await client.post('/v1/chat/completions', {
224
239
  body: { model: faceModel, messages: [{ role: 'user', content: prompt }], max_tokens: 256 },
@@ -19,8 +19,10 @@ export default class ChatChat extends BaseCommand {
19
19
  face_username: import("@oclif/core/interfaces").Arg<string, Record<string, unknown>>;
20
20
  };
21
21
  run(): Promise<unknown>;
22
+ private oauthHint;
23
+ private metaFor;
24
+ private streamDeltas;
22
25
  private runChatCompletions;
23
26
  private runMessages;
24
27
  private runResponses;
25
- private extractText;
26
28
  }
@@ -3,14 +3,9 @@ import fs from 'node:fs';
3
3
  import { BaseCommand } from '../../base.js';
4
4
  import { FacesAPIError } from '../../client.js';
5
5
  import { extractStreamDelta } from '../../utils.js';
6
- function isAnthropicModel(model) {
7
- // Extract model name from face@model format
8
- const parts = model.split('@');
9
- const llm = parts.length > 1 ? parts[parts.length - 1] : '';
10
- return llm.startsWith('claude');
11
- }
6
+ import { resolveEndpoint, MESSAGES_ENDPOINT, RESPONSES_ENDPOINT } from '../../routing.js';
12
7
  export default class ChatChat extends BaseCommand {
13
- static description = 'Chat via a face. Auto-routes to the correct API endpoint based on model provider.';
8
+ static description = 'Chat via a face. Auto-routes to the correct API endpoint based on the model catalog.';
14
9
  static flags = {
15
10
  ...BaseCommand.baseFlags,
16
11
  message: Flags.string({ char: 'm', description: 'User message (repeatable)', multiple: true, required: false }),
@@ -20,7 +15,7 @@ export default class ChatChat extends BaseCommand {
20
15
  'max-tokens': Flags.integer({ description: 'Max tokens' }),
21
16
  temperature: Flags.string({ description: 'Temperature (0.0-2.0)' }),
22
17
  file: Flags.string({ description: 'Read message from file' }),
23
- responses: Flags.boolean({ description: 'Use OpenAI Responses API instead of Chat Completions', default: false }),
18
+ responses: Flags.boolean({ description: 'Force the OpenAI Responses API endpoint (escape hatch; routing is automatic otherwise)', default: false }),
24
19
  'oauth-only': Flags.boolean({ description: 'Prevent fallback to paid system key (use OAuth only)', default: false }),
25
20
  };
26
21
  static args = {
@@ -51,15 +46,15 @@ export default class ChatChat extends BaseCommand {
51
46
  else {
52
47
  this.error('Provide at least one --message/-m or --file');
53
48
  }
54
- // Route based on model provider
55
- const anthropic = isAnthropicModel(model);
56
- const useResponses = flags.responses && !anthropic;
49
+ // Route off the model catalog; --responses forces the Responses endpoint.
50
+ const route = await resolveEndpoint(client, model);
51
+ const endpoint = flags.responses ? RESPONSES_ENDPOINT : route.endpoint;
57
52
  try {
58
- if (anthropic) {
53
+ if (endpoint === MESSAGES_ENDPOINT) {
59
54
  return await this.runMessages(client, model, userMessages, flags);
60
55
  }
61
- else if (useResponses) {
62
- return await this.runResponses(client, model, userMessages[0], flags);
56
+ else if (endpoint === RESPONSES_ENDPOINT) {
57
+ return await this.runResponses(client, model, userMessages, flags);
63
58
  }
64
59
  else {
65
60
  return await this.runChatCompletions(client, model, userMessages, flags);
@@ -67,17 +62,48 @@ export default class ChatChat extends BaseCommand {
67
62
  }
68
63
  catch (err) {
69
64
  if (err instanceof FacesAPIError) {
70
- if (err.errorCode === 'oauth_rejected') {
71
- const hint = err.fallbackAvailable
72
- ? 'Run `faces account:preferences api_fallback true` to allow paid fallback.'
73
- : 'Run `faces billing:topup` to add credits, then enable fallback with `faces account:preferences api_fallback true`.';
74
- this.error(`OAuth failed: ${err.message}\n${hint}`);
75
- }
65
+ if (err.errorCode === 'oauth_rejected')
66
+ this.error(`OAuth failed: ${err.message}\n${this.oauthHint(err, { ...route, endpoint })}`);
76
67
  this.error(`Error (${err.statusCode}): ${err.message}`);
77
68
  }
78
69
  throw err;
79
70
  }
80
71
  }
72
+ oauthHint(err, route) {
73
+ const hints = [];
74
+ if (route.endpoint === RESPONSES_ENDPOINT && route.freeWhenConnected) {
75
+ hints.push('This model is free when you link your ChatGPT account: faces auth:connect openai');
76
+ }
77
+ hints.push(err.fallbackAvailable
78
+ ? 'Run `faces account:preferences api_fallback true` to allow paid fallback.'
79
+ : 'Run `faces billing:topup` to add credits, then enable fallback with `faces account:preferences api_fallback true`.');
80
+ return hints.join('\n');
81
+ }
82
+ metaFor(headers, endpoint) {
83
+ const meta = {};
84
+ if (headers['x-faces-provider'])
85
+ meta.provider = headers['x-faces-provider'];
86
+ if (headers['x-faces-cost-usd'])
87
+ meta.cost_usd = headers['x-faces-cost-usd'];
88
+ meta.endpoint = endpoint;
89
+ return meta;
90
+ }
91
+ async streamDeltas(client, endpoint, payload) {
92
+ for await (const line of client.stream(endpoint, payload)) {
93
+ if (!line.startsWith('data: '))
94
+ continue;
95
+ const chunk = line.slice(6);
96
+ if (chunk.trim() === '[DONE]' || chunk.trim() === '')
97
+ continue;
98
+ try {
99
+ const delta = extractStreamDelta(JSON.parse(chunk));
100
+ if (delta)
101
+ process.stdout.write(delta);
102
+ }
103
+ catch { /* ignore parse errors */ }
104
+ }
105
+ process.stdout.write('\n');
106
+ }
81
107
  async runChatCompletions(client, model, userMessages, flags) {
82
108
  const messages = [];
83
109
  if (flags.system)
@@ -92,39 +118,15 @@ export default class ChatChat extends BaseCommand {
92
118
  if (flags['oauth-only'])
93
119
  payload.oauth_only = true;
94
120
  if (flags.stream) {
95
- // The backend may re-route /v1/chat/completions to /v1/messages or
96
- // /v1/responses, so handle all three SSE event shapes.
97
- for await (const line of client.stream('/v1/chat/completions', payload)) {
98
- if (!line.startsWith('data: '))
99
- continue;
100
- const chunk = line.slice(6);
101
- if (chunk.trim() === '[DONE]' || chunk.trim() === '')
102
- continue;
103
- try {
104
- const delta = extractStreamDelta(JSON.parse(chunk));
105
- if (delta)
106
- process.stdout.write(delta);
107
- }
108
- catch { /* ignore parse errors */ }
109
- }
110
- process.stdout.write('\n');
121
+ await this.streamDeltas(client, '/v1/chat/completions', payload);
111
122
  return {};
112
123
  }
113
124
  const { data, headers } = await client.postWithHeaders('/v1/chat/completions', { body: payload });
114
125
  const body = data;
115
- const routedEndpoint = headers['x-faces-routed-endpoint'];
116
- // Extract text based on which format the backend used
117
- const text = this.extractText(body, routedEndpoint);
126
+ const choices = body.choices;
127
+ const text = String(choices?.[0]?.message?.content ?? '');
118
128
  if (this.jsonEnabled()) {
119
- const meta = {};
120
- if (headers['x-faces-provider'])
121
- meta.provider = headers['x-faces-provider'];
122
- if (headers['x-faces-cost-usd'])
123
- meta.cost_usd = headers['x-faces-cost-usd'];
124
- if (routedEndpoint)
125
- meta.routed_endpoint = routedEndpoint;
126
- if (Object.keys(meta).length > 0)
127
- body._meta = meta;
129
+ body._meta = this.metaFor(headers, '/v1/chat/completions');
128
130
  return body;
129
131
  }
130
132
  this.log(text);
@@ -143,32 +145,13 @@ export default class ChatChat extends BaseCommand {
143
145
  if (flags['oauth-only'])
144
146
  payload.oauth_only = true;
145
147
  if (flags.stream) {
146
- for await (const line of client.stream('/v1/messages', payload)) {
147
- if (!line.startsWith('data: '))
148
- continue;
149
- const chunk = line.slice(6);
150
- if (chunk.trim() === '[DONE]' || chunk.trim() === '')
151
- continue;
152
- try {
153
- const delta = extractStreamDelta(JSON.parse(chunk));
154
- if (delta)
155
- process.stdout.write(delta);
156
- }
157
- catch { /* ignore */ }
158
- }
159
- process.stdout.write('\n');
148
+ await this.streamDeltas(client, '/v1/messages', payload);
160
149
  return {};
161
150
  }
162
151
  const { data, headers } = await client.postWithHeaders('/v1/messages', { body: payload });
163
152
  const body = data;
164
153
  if (this.jsonEnabled()) {
165
- const meta = {};
166
- if (headers['x-faces-provider'])
167
- meta.provider = headers['x-faces-provider'];
168
- if (headers['x-faces-cost-usd'])
169
- meta.cost_usd = headers['x-faces-cost-usd'];
170
- if (Object.keys(meta).length > 0)
171
- body._meta = meta;
154
+ body._meta = this.metaFor(headers, '/v1/messages');
172
155
  return body;
173
156
  }
174
157
  let content = '';
@@ -179,39 +162,24 @@ export default class ChatChat extends BaseCommand {
179
162
  this.log(content);
180
163
  return body;
181
164
  }
182
- async runResponses(client, model, input, flags) {
183
- const payload = { model, input, stream: flags.stream };
165
+ async runResponses(client, model, userMessages, flags) {
166
+ const payload = {
167
+ model,
168
+ input: userMessages.map((m) => ({ role: 'user', content: m })),
169
+ stream: flags.stream,
170
+ };
184
171
  if (flags.system)
185
172
  payload.instructions = flags.system;
186
173
  if (flags['oauth-only'])
187
174
  payload.oauth_only = true;
188
175
  if (flags.stream) {
189
- for await (const line of client.stream('/v1/responses', payload)) {
190
- if (!line.startsWith('data: '))
191
- continue;
192
- const chunk = line.slice(6);
193
- if (chunk.trim() === '[DONE]' || chunk.trim() === '')
194
- continue;
195
- try {
196
- const delta = extractStreamDelta(JSON.parse(chunk));
197
- if (delta)
198
- process.stdout.write(delta);
199
- }
200
- catch { /* ignore */ }
201
- }
202
- process.stdout.write('\n');
176
+ await this.streamDeltas(client, '/v1/responses', payload);
203
177
  return {};
204
178
  }
205
179
  const { data, headers } = await client.postWithHeaders('/v1/responses', { body: payload });
206
180
  const body = data;
207
181
  if (this.jsonEnabled()) {
208
- const meta = {};
209
- if (headers['x-faces-provider'])
210
- meta.provider = headers['x-faces-provider'];
211
- if (headers['x-faces-cost-usd'])
212
- meta.cost_usd = headers['x-faces-cost-usd'];
213
- if (Object.keys(meta).length > 0)
214
- body._meta = meta;
182
+ body._meta = this.metaFor(headers, '/v1/responses');
215
183
  return body;
216
184
  }
217
185
  let outputText = '';
@@ -224,30 +192,4 @@ export default class ChatChat extends BaseCommand {
224
192
  this.log(outputText);
225
193
  return body;
226
194
  }
227
- extractText(body, routedEndpoint) {
228
- if (routedEndpoint === '/v1/responses') {
229
- // OpenAI Responses format
230
- let text = '';
231
- for (const item of body.output ?? []) {
232
- for (const block of item.content ?? []) {
233
- if (block.type === 'output_text')
234
- text += String(block.text ?? '');
235
- }
236
- }
237
- return text;
238
- }
239
- if (routedEndpoint === '/v1/messages') {
240
- // Anthropic Messages format
241
- let text = '';
242
- for (const block of body.content ?? []) {
243
- if (block.type === 'text')
244
- text += String(block.text ?? '');
245
- }
246
- return text;
247
- }
248
- // Standard chat/completions format (default)
249
- const choices = body.choices;
250
- const message = choices?.[0]?.message;
251
- return String(message?.content ?? '');
252
- }
253
195
  }
@@ -27,12 +27,13 @@ export default class ChatThread extends BaseCommand {
27
27
  private handleDelete;
28
28
  private handleShow;
29
29
  private handleMessage;
30
- private sendOpenAI;
30
+ private sendChatCompletions;
31
+ private sendResponses;
31
32
  private sendAnthropic;
32
33
  /**
33
34
  * Stream an SSE response, printing deltas to stdout as they arrive while
34
- * accumulating the full text to persist on the thread. Handles any of the
35
- * three event shapes, so a re-routed stream still renders correctly.
35
+ * accumulating the full text to persist on the thread. extractStreamDelta
36
+ * dispatches on event type, so it renders whichever endpoint we posted to.
36
37
  */
37
38
  private streamAndAccumulate;
38
39
  }
@@ -6,32 +6,23 @@ import path from 'node:path';
6
6
  import { BaseCommand } from '../../base.js';
7
7
  import { FacesAPIError } from '../../client.js';
8
8
  import { extractStreamDelta } from '../../utils.js';
9
+ import { resolveEndpoint, MESSAGES_ENDPOINT, RESPONSES_ENDPOINT, CHAT_COMPLETIONS_ENDPOINT } from '../../routing.js';
9
10
  const THREADS_DIR = path.join(os.homedir(), '.faces', 'threads');
10
- function isAnthropicModel(model) {
11
- const parts = model.split('@');
12
- const llm = parts.length > 1 ? parts[parts.length - 1] : '';
13
- return llm.startsWith('claude');
14
- }
15
11
  /**
16
- * Extract assistant text from any response body shape. The backend may serve a
17
- * request at the endpoint we hit, or re-route it (signalled by the
18
- * x-faces-routed-endpoint header) — so handle all three families regardless of
19
- * which endpoint we posted to: Anthropic Messages, OpenAI Responses, and Chat
12
+ * Extract assistant text for a known endpoint. Routing is deterministic, so we
13
+ * parse exactly one shape: Anthropic Messages, OpenAI Responses, or Chat
20
14
  * Completions.
21
15
  */
22
- function extractAssistantText(body, routedEndpoint) {
23
- // Anthropic Messages shape: { type: 'message', content: [{type:'text', text}] }
24
- if (routedEndpoint === '/v1/messages' || (Array.isArray(body.content) && body.type === 'message')) {
16
+ function extractAssistantText(body, endpoint) {
17
+ if (endpoint === MESSAGES_ENDPOINT) {
25
18
  let text = '';
26
19
  for (const block of body.content ?? []) {
27
20
  if (block.type === 'text')
28
21
  text += String(block.text ?? '');
29
22
  }
30
- if (text)
31
- return text;
23
+ return text;
32
24
  }
33
- // OpenAI Responses shape: { output: [{content: [{type:'output_text', text}]}] }
34
- if (routedEndpoint === '/v1/responses' || Array.isArray(body.output)) {
25
+ if (endpoint === RESPONSES_ENDPOINT) {
35
26
  let text = '';
36
27
  for (const item of body.output ?? []) {
37
28
  for (const block of item.content ?? []) {
@@ -39,13 +30,11 @@ function extractAssistantText(body, routedEndpoint) {
39
30
  text += String(block.text ?? '');
40
31
  }
41
32
  }
42
- if (text)
43
- return text;
33
+ return text;
44
34
  }
45
- // Standard Chat Completions shape: { choices: [{message: {content}}] }
35
+ // Chat Completions: { choices: [{message: {content}}] }
46
36
  const choices = body.choices;
47
- const message = choices?.[0]?.message;
48
- return String(message?.content ?? '');
37
+ return String(choices?.[0]?.message?.content ?? '');
49
38
  }
50
39
  function newThreadId() {
51
40
  return 't_' + crypto.randomBytes(6).toString('hex');
@@ -219,7 +208,7 @@ export default class ChatThread extends BaseCommand {
219
208
  id: newThreadId(),
220
209
  face_username: baseAlias,
221
210
  model,
222
- provider: isAnthropicModel(model) ? 'anthropic' : 'openai',
211
+ provider: 'openai', // refined from the resolved endpoint before sending
223
212
  system: flags.system,
224
213
  created_at: now,
225
214
  updated_at: now,
@@ -227,10 +216,25 @@ export default class ChatThread extends BaseCommand {
227
216
  };
228
217
  }
229
218
  thread.messages.push({ role: 'user', content: userMessage, ts: new Date().toISOString() });
219
+ // Route off the model catalog; cache the endpoint on the thread so resumes
220
+ // (including legacy threads saved before `endpoint` existed) stay consistent.
221
+ let endpoint = thread.endpoint;
222
+ if (!endpoint) {
223
+ endpoint = (await resolveEndpoint(client, thread.model)).endpoint;
224
+ thread.endpoint = endpoint;
225
+ thread.provider = endpoint === MESSAGES_ENDPOINT ? 'anthropic' : 'openai';
226
+ }
230
227
  try {
231
- const assistantText = thread.provider === 'anthropic'
232
- ? await this.sendAnthropic(client, thread, flags)
233
- : await this.sendOpenAI(client, thread, flags);
228
+ let assistantText;
229
+ if (endpoint === MESSAGES_ENDPOINT) {
230
+ assistantText = await this.sendAnthropic(client, thread, flags);
231
+ }
232
+ else if (endpoint === RESPONSES_ENDPOINT) {
233
+ assistantText = await this.sendResponses(client, thread, flags);
234
+ }
235
+ else {
236
+ assistantText = await this.sendChatCompletions(client, thread, flags);
237
+ }
234
238
  thread.messages.push({ role: 'assistant', content: assistantText, ts: new Date().toISOString() });
235
239
  saveThread(thread);
236
240
  if (this.jsonEnabled()) {
@@ -250,17 +254,21 @@ export default class ChatThread extends BaseCommand {
250
254
  catch (err) {
251
255
  if (err instanceof FacesAPIError) {
252
256
  if (err.errorCode === 'oauth_rejected') {
253
- const hint = err.fallbackAvailable
257
+ const hints = [];
258
+ if (endpoint === RESPONSES_ENDPOINT) {
259
+ hints.push('This model is free when you link your ChatGPT account: faces auth:connect openai');
260
+ }
261
+ hints.push(err.fallbackAvailable
254
262
  ? 'Run `faces account:preferences api_fallback true` to allow paid fallback.'
255
- : 'Run `faces billing:topup` to add credits, then enable fallback with `faces account:preferences api_fallback true`.';
256
- this.error(`OAuth failed: ${err.message}\n${hint}`);
263
+ : 'Run `faces billing:topup` to add credits, then enable fallback with `faces account:preferences api_fallback true`.');
264
+ this.error(`OAuth failed: ${err.message}\n${hints.join('\n')}`);
257
265
  }
258
266
  this.error(`Error (${err.statusCode}): ${err.message}`);
259
267
  }
260
268
  throw err;
261
269
  }
262
270
  }
263
- async sendOpenAI(client, thread, flags) {
271
+ async sendChatCompletions(client, thread, flags) {
264
272
  const messages = [];
265
273
  if (thread.system)
266
274
  messages.push({ role: 'system', content: thread.system });
@@ -274,10 +282,27 @@ export default class ChatThread extends BaseCommand {
274
282
  if (flags['oauth-only'])
275
283
  payload.oauth_only = true;
276
284
  if (flags.stream) {
277
- return this.streamAndAccumulate(client, '/v1/chat/completions', payload);
285
+ return this.streamAndAccumulate(client, CHAT_COMPLETIONS_ENDPOINT, payload);
286
+ }
287
+ const { data } = await client.postWithHeaders(CHAT_COMPLETIONS_ENDPOINT, { body: payload });
288
+ return extractAssistantText(data, CHAT_COMPLETIONS_ENDPOINT);
289
+ }
290
+ async sendResponses(client, thread, flags) {
291
+ // The Responses API is stateless here: replay the full history as the input
292
+ // array each turn, with the system prompt carried in `instructions`.
293
+ const input = thread.messages.map((m) => ({ role: m.role, content: m.content }));
294
+ const payload = { model: thread.model, input, stream: flags.stream };
295
+ if (thread.system)
296
+ payload.instructions = thread.system;
297
+ if (flags['max-tokens'])
298
+ payload.max_output_tokens = flags['max-tokens'];
299
+ if (flags['oauth-only'])
300
+ payload.oauth_only = true;
301
+ if (flags.stream) {
302
+ return this.streamAndAccumulate(client, RESPONSES_ENDPOINT, payload);
278
303
  }
279
- const { data, headers } = await client.postWithHeaders('/v1/chat/completions', { body: payload });
280
- return extractAssistantText(data, headers['x-faces-routed-endpoint']);
304
+ const { data } = await client.postWithHeaders(RESPONSES_ENDPOINT, { body: payload });
305
+ return extractAssistantText(data, RESPONSES_ENDPOINT);
281
306
  }
282
307
  async sendAnthropic(client, thread, flags) {
283
308
  const msgs = thread.messages.map((m) => ({ role: m.role, content: m.content }));
@@ -292,15 +317,15 @@ export default class ChatThread extends BaseCommand {
292
317
  if (flags['oauth-only'])
293
318
  payload.oauth_only = true;
294
319
  if (flags.stream) {
295
- return this.streamAndAccumulate(client, '/v1/messages', payload);
320
+ return this.streamAndAccumulate(client, MESSAGES_ENDPOINT, payload);
296
321
  }
297
- const { data, headers } = await client.postWithHeaders('/v1/messages', { body: payload });
298
- return extractAssistantText(data, headers['x-faces-routed-endpoint']);
322
+ const { data } = await client.postWithHeaders(MESSAGES_ENDPOINT, { body: payload });
323
+ return extractAssistantText(data, MESSAGES_ENDPOINT);
299
324
  }
300
325
  /**
301
326
  * Stream an SSE response, printing deltas to stdout as they arrive while
302
- * accumulating the full text to persist on the thread. Handles any of the
303
- * three event shapes, so a re-routed stream still renders correctly.
327
+ * accumulating the full text to persist on the thread. extractStreamDelta
328
+ * dispatches on event type, so it renders whichever endpoint we posted to.
304
329
  */
305
330
  async streamAndAccumulate(client, endpoint, payload) {
306
331
  let accum = '';
@@ -0,0 +1,19 @@
1
+ import { FacesClient } from './client.js';
2
+ export declare const MESSAGES_ENDPOINT = "/v1/messages";
3
+ export declare const RESPONSES_ENDPOINT = "/v1/responses";
4
+ export declare const CHAT_COMPLETIONS_ENDPOINT = "/v1/chat/completions";
5
+ export interface RouteInfo {
6
+ endpoint: string;
7
+ /** The resolved LLM id we routed on (undefined if it couldn't be determined). */
8
+ llm?: string;
9
+ /** True when a free (OAuth-subscription) variant of this model exists in the catalog. */
10
+ freeWhenConnected: boolean;
11
+ }
12
+ /** Split `alias@llm` → llm; bare `alias` → undefined. */
13
+ export declare function llmFromArg(modelArg: string): string | undefined;
14
+ /**
15
+ * Decide which API endpoint a chat request should be posted to, driven entirely
16
+ * by the model catalog (GET /v1/models). For a bare alias we resolve the face's
17
+ * default model from the local catalog, falling back to GET /v1/faces/{alias}.
18
+ */
19
+ export declare function resolveEndpoint(client: FacesClient, modelArg: string): Promise<RouteInfo>;
@@ -0,0 +1,116 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { CATALOG_INDEX } from './catalog.js';
5
+ export const MESSAGES_ENDPOINT = '/v1/messages';
6
+ export const RESPONSES_ENDPOINT = '/v1/responses';
7
+ export const CHAT_COMPLETIONS_ENDPOINT = '/v1/chat/completions';
8
+ const CACHE_PATH = path.join(os.homedir(), '.faces', 'models-cache.json');
9
+ const TTL_MS = 60 * 60 * 1000; // 1 hour
10
+ /** Last-resort routing when the catalog can't tell us where a model lives. */
11
+ function fallbackEndpoint(llm) {
12
+ if (llm && llm.startsWith('claude'))
13
+ return MESSAGES_ENDPOINT;
14
+ return CHAT_COMPLETIONS_ENDPOINT;
15
+ }
16
+ function readCache() {
17
+ try {
18
+ return JSON.parse(fs.readFileSync(CACHE_PATH, 'utf8'));
19
+ }
20
+ catch {
21
+ return null;
22
+ }
23
+ }
24
+ function writeCache(models) {
25
+ try {
26
+ const dir = path.dirname(CACHE_PATH);
27
+ if (!fs.existsSync(dir))
28
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
29
+ fs.writeFileSync(CACHE_PATH, JSON.stringify({ fetched_at: Date.now(), models }), { mode: 0o600 });
30
+ }
31
+ catch {
32
+ // a cache write failure is non-fatal
33
+ }
34
+ }
35
+ async function fetchModels(client) {
36
+ const raw = (await client.get('/v1/models'));
37
+ const data = Array.isArray(raw) ? raw : (raw.data ?? raw.models ?? []);
38
+ return data;
39
+ }
40
+ /**
41
+ * Return the list of catalog models, served from a short-lived on-disk cache.
42
+ * Falls back to a stale cache if the network fetch fails, and to an empty list
43
+ * if there's nothing cached — callers degrade to {@link fallbackEndpoint}.
44
+ */
45
+ async function getModels(client) {
46
+ const cache = readCache();
47
+ if (cache && Date.now() - cache.fetched_at < TTL_MS)
48
+ return cache.models;
49
+ try {
50
+ const models = await fetchModels(client);
51
+ writeCache(models);
52
+ return models;
53
+ }
54
+ catch {
55
+ return cache?.models ?? [];
56
+ }
57
+ }
58
+ /** Build an id → {endpoint, freeWhenConnected} map, deduping paid/oauth twins. */
59
+ function buildMap(models) {
60
+ const map = new Map();
61
+ for (const m of models) {
62
+ if (!m.id || !m.endpoint)
63
+ continue;
64
+ const existing = map.get(m.id);
65
+ const isOauth = m.provider === 'openai_oauth';
66
+ if (existing) {
67
+ existing.freeWhenConnected = existing.freeWhenConnected || isOauth;
68
+ }
69
+ else {
70
+ map.set(m.id, { endpoint: m.endpoint, freeWhenConnected: isOauth });
71
+ }
72
+ }
73
+ return map;
74
+ }
75
+ /** Read a face's default_model from the local catalog index without a network call. */
76
+ function defaultModelFromCatalog(alias) {
77
+ try {
78
+ const entries = JSON.parse(fs.readFileSync(CATALOG_INDEX, 'utf8'));
79
+ const hit = entries.find((e) => e.alias === alias);
80
+ return hit?.default_model || undefined;
81
+ }
82
+ catch {
83
+ return undefined;
84
+ }
85
+ }
86
+ /** Split `alias@llm` → llm; bare `alias` → undefined. */
87
+ export function llmFromArg(modelArg) {
88
+ const i = modelArg.lastIndexOf('@');
89
+ return i >= 0 ? modelArg.slice(i + 1) : undefined;
90
+ }
91
+ /**
92
+ * Decide which API endpoint a chat request should be posted to, driven entirely
93
+ * by the model catalog (GET /v1/models). For a bare alias we resolve the face's
94
+ * default model from the local catalog, falling back to GET /v1/faces/{alias}.
95
+ */
96
+ export async function resolveEndpoint(client, modelArg) {
97
+ let llm = llmFromArg(modelArg);
98
+ if (!llm) {
99
+ const alias = modelArg.split('@')[0];
100
+ llm = defaultModelFromCatalog(alias);
101
+ if (!llm) {
102
+ try {
103
+ const face = (await client.get(`/v1/faces/${encodeURIComponent(alias)}`));
104
+ llm = face.default_model || undefined;
105
+ }
106
+ catch {
107
+ // fall through to heuristic
108
+ }
109
+ }
110
+ }
111
+ const map = buildMap(await getModels(client));
112
+ const hit = llm ? map.get(llm) : undefined;
113
+ if (hit)
114
+ return { endpoint: hit.endpoint, llm, freeWhenConnected: hit.freeWhenConnected };
115
+ return { endpoint: fallbackEndpoint(llm), llm, freeWhenConnected: false };
116
+ }