faces-cli 1.6.18 → 1.7.1

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,11 +15,11 @@ 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 = {
27
- face_username: Args.string({ description: 'Face alias (or alias@model)', required: true }),
22
+ face_username: Args.string({ description: 'Face alias (alias@model, or owner:alias@model for a published face)', required: true }),
28
23
  };
29
24
  async run() {
30
25
  const { args, flags } = await this.parse(ChatChat);
@@ -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
  }
@@ -12,7 +12,7 @@ export default class ChatMessages extends BaseCommand {
12
12
  'oauth-only': Flags.boolean({ description: 'Prevent fallback to paid system key (use OAuth only)', default: false }),
13
13
  };
14
14
  static args = {
15
- face_model: Args.string({ description: 'Face model (e.g. myface or myface@claude-sonnet-4-6)', required: true }),
15
+ face_model: Args.string({ description: 'Face model (e.g. myface, myface@claude-sonnet-4-6, or head:judge@claude-sonnet-4-6)', required: true }),
16
16
  };
17
17
  async run() {
18
18
  const { args, flags } = await this.parse(ChatMessages);
@@ -11,7 +11,7 @@ export default class ChatResponses extends BaseCommand {
11
11
  'oauth-only': Flags.boolean({ description: 'Prevent fallback to paid system key (use OAuth only)', default: false }),
12
12
  };
13
13
  static args = {
14
- face_model: Args.string({ description: 'Face model (e.g. myface or myface@gpt-4o)', required: true }),
14
+ face_model: Args.string({ description: 'Face model (e.g. myface, myface@gpt-4o, or head:logician@gpt-5.4)', required: true }),
15
15
  };
16
16
  async run() {
17
17
  const { args, flags } = await this.parse(ChatResponses);
@@ -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');
@@ -109,7 +98,7 @@ export default class ChatThread extends BaseCommand {
109
98
  };
110
99
  static args = {
111
100
  face_username: Args.string({
112
- description: 'Face alias (or alias@model) — required when starting a new thread',
101
+ description: 'Face alias (alias@model, or owner:alias@model for a published face) — required when starting a new thread',
113
102
  required: false,
114
103
  }),
115
104
  };
@@ -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 = '';
@@ -5,6 +5,10 @@ export default class FaceList extends BaseCommand {
5
5
  tag: import("@oclif/core/interfaces").OptionFlag<string[] | undefined, import("@oclif/core/interfaces").CustomOptions>;
6
6
  team: import("@oclif/core/interfaces").OptionFlag<string[] | undefined, import("@oclif/core/interfaces").CustomOptions>;
7
7
  include: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
8
+ public: import("@oclif/core/interfaces").BooleanFlag<boolean>;
9
+ system: import("@oclif/core/interfaces").BooleanFlag<boolean>;
10
+ 'from-users': import("@oclif/core/interfaces").OptionFlag<string[] | undefined, import("@oclif/core/interfaces").CustomOptions>;
11
+ 'not-from-users': import("@oclif/core/interfaces").OptionFlag<string[] | undefined, import("@oclif/core/interfaces").CustomOptions>;
8
12
  'base-url': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
9
13
  token: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
10
14
  'api-key': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
@@ -2,17 +2,34 @@ import { Flags } from '@oclif/core';
2
2
  import { BaseCommand } from '../../base.js';
3
3
  import { FacesAPIError } from '../../client.js';
4
4
  import { renameFaceFields } from '../../utils.js';
5
+ /** Curated system faces are served under this owner account. */
6
+ const SYSTEM_OWNER = 'head';
5
7
  export default class FaceList extends BaseCommand {
6
- static description = 'List all owned faces';
8
+ static description = 'List faces. By default lists owned faces; use --public to include published faces from other accounts.';
7
9
  static flags = {
8
10
  ...BaseCommand.baseFlags,
9
11
  tag: Flags.string({ description: 'Filter by tag (repeatable, AND logic)', multiple: true }),
10
12
  team: Flags.string({ description: 'Filter by team ID (repeatable, OR logic)', multiple: true }),
11
13
  include: Flags.string({ description: 'Include extra fields: tags, teams (comma-separated)' }),
14
+ public: Flags.boolean({ description: 'Include published faces from other accounts' }),
15
+ system: Flags.boolean({ description: `Show only the curated system faces (published faces owned by '${SYSTEM_OWNER}')` }),
16
+ 'from-users': Flags.string({ description: 'Only published faces from these owners (repeatable/comma-separated)', multiple: true }),
17
+ 'not-from-users': Flags.string({ description: 'Exclude published faces from these owners (repeatable/comma-separated)', multiple: true }),
12
18
  };
13
19
  async run() {
14
20
  const { flags } = await this.parse(FaceList);
15
21
  const client = this.makeClient(flags);
22
+ // Split comma-separated owner lists into individual values.
23
+ const split = (vals) => (vals ?? []).flatMap(v => v.split(',')).map(v => v.trim()).filter(Boolean);
24
+ const fromUsers = split(flags['from-users']);
25
+ if (flags.system)
26
+ fromUsers.push(SYSTEM_OWNER);
27
+ const notFromUsers = split(flags['not-from-users']);
28
+ if (fromUsers.length > 0 && notFromUsers.length > 0) {
29
+ this.error('--from-users and --not-from-users are mutually exclusive');
30
+ }
31
+ // Any cross-account owner filter implies we want published faces.
32
+ const includePublished = flags.public || flags.system || fromUsers.length > 0 || notFromUsers.length > 0;
16
33
  // Build query string manually for repeated params
17
34
  const parts = [];
18
35
  if (flags.tag && flags.tag.length > 0) {
@@ -25,6 +42,12 @@ export default class FaceList extends BaseCommand {
25
42
  }
26
43
  if (flags.include)
27
44
  parts.push(`include=${encodeURIComponent(flags.include)}`);
45
+ if (includePublished)
46
+ parts.push('include_published=true');
47
+ for (const u of fromUsers)
48
+ parts.push(`from_users=${encodeURIComponent(u)}`);
49
+ for (const u of notFromUsers)
50
+ parts.push(`not_from_users=${encodeURIComponent(u)}`);
28
51
  let data;
29
52
  try {
30
53
  const path = parts.length > 0 ? `/v1/faces?${parts.join('&')}` : '/v1/faces';
@@ -40,13 +63,31 @@ export default class FaceList extends BaseCommand {
40
63
  for (const f of raw) {
41
64
  renameFaceFields(f);
42
65
  }
66
+ // The API's from_users/not_from_users only constrain the *appended published*
67
+ // set — the caller's own faces always come back. Filter client-side so
68
+ // --system / --from-users / --not-from-users actually mean "from these owners".
69
+ let faces = raw;
70
+ if (fromUsers.length > 0) {
71
+ const allow = new Set(fromUsers);
72
+ faces = faces.filter(f => f.owned_by != null && allow.has(f.owned_by));
73
+ }
74
+ else if (notFromUsers.length > 0) {
75
+ const block = new Set(notFromUsers);
76
+ faces = faces.filter(f => f.owned_by == null || !block.has(f.owned_by));
77
+ }
78
+ // Keep --json output consistent with what we display.
79
+ if (data.data !== undefined)
80
+ data.data = faces;
81
+ else
82
+ data = faces;
43
83
  if (!this.jsonEnabled()) {
44
- const faces = raw;
84
+ // Published faces are chatted as `owner:alias`; own faces stay bare.
85
+ const handle = (f) => f.published && f.owned_by ? `${f.owned_by}:${f.alias}` : f.alias;
45
86
  if (faces.length === 0) {
46
87
  this.log('(no faces)');
47
88
  }
48
89
  else {
49
- const aliasWidth = Math.max(...faces.map(f => f.alias.length));
90
+ const handleWidth = Math.max(...faces.map(f => handle(f).length));
50
91
  const nameWidth = Math.max(...faces.map(f => f.name.length));
51
92
  for (const f of faces) {
52
93
  let suffix = '';
@@ -59,7 +100,9 @@ export default class FaceList extends BaseCommand {
59
100
  const profile = f.profile_token_count ?? 0;
60
101
  suffix = ` [profile: ${profile} tok, components: ${total}]`;
61
102
  }
62
- this.log(`${f.alias.padEnd(aliasWidth)} ${f.name.padEnd(nameWidth)}${suffix}`);
103
+ if (f.published)
104
+ suffix += ' [public · requires @model]';
105
+ this.log(`${handle(f).padEnd(handleWidth)} ${f.name.padEnd(nameWidth)}${suffix}`);
63
106
  }
64
107
  }
65
108
  }