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.
- package/dist/commands/catalog/doctor.js +17 -2
- package/dist/commands/chat/chat.d.ts +3 -1
- package/dist/commands/chat/chat.js +61 -119
- package/dist/commands/chat/messages.js +1 -1
- package/dist/commands/chat/responses.js +1 -1
- package/dist/commands/chat/thread.d.ts +4 -3
- package/dist/commands/chat/thread.js +63 -38
- package/dist/commands/face/list.d.ts +4 -0
- package/dist/commands/face/list.js +47 -4
- package/dist/routing.d.ts +19 -0
- package/dist/routing.js +116 -0
- package/oclif.manifest.json +1042 -1016
- package/package.json +1 -1
|
@@ -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 (
|
|
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
|
-
|
|
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
|
|
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: '
|
|
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
|
|
55
|
-
const
|
|
56
|
-
const
|
|
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 (
|
|
53
|
+
if (endpoint === MESSAGES_ENDPOINT) {
|
|
59
54
|
return await this.runMessages(client, model, userMessages, flags);
|
|
60
55
|
}
|
|
61
|
-
else if (
|
|
62
|
-
return await this.runResponses(client, model, userMessages
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
116
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
183
|
-
const payload = {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
35
|
-
*
|
|
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
|
|
17
|
-
*
|
|
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,
|
|
23
|
-
|
|
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
|
-
|
|
31
|
-
return text;
|
|
23
|
+
return text;
|
|
32
24
|
}
|
|
33
|
-
|
|
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
|
-
|
|
43
|
-
return text;
|
|
33
|
+
return text;
|
|
44
34
|
}
|
|
45
|
-
//
|
|
35
|
+
// Chat Completions: { choices: [{message: {content}}] }
|
|
46
36
|
const choices = body.choices;
|
|
47
|
-
|
|
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:
|
|
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
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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
|
|
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${
|
|
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
|
|
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,
|
|
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
|
|
280
|
-
return extractAssistantText(data,
|
|
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,
|
|
320
|
+
return this.streamAndAccumulate(client, MESSAGES_ENDPOINT, payload);
|
|
296
321
|
}
|
|
297
|
-
const { data
|
|
298
|
-
return extractAssistantText(data,
|
|
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.
|
|
303
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
}
|