faces-cli 1.6.15 → 1.6.17
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/chat/chat.js +13 -18
- package/dist/commands/chat/thread.d.ts +38 -0
- package/dist/commands/chat/thread.js +325 -0
- package/dist/commands/keys/create.d.ts +1 -0
- package/dist/commands/keys/create.js +35 -4
- package/dist/utils.d.ts +10 -0
- package/dist/utils.js +19 -0
- package/oclif.manifest.json +661 -509
- package/package.json +1 -1
|
@@ -2,6 +2,7 @@ import { Args, Flags } from '@oclif/core';
|
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
import { BaseCommand } from '../../base.js';
|
|
4
4
|
import { FacesAPIError } from '../../client.js';
|
|
5
|
+
import { extractStreamDelta } from '../../utils.js';
|
|
5
6
|
function isAnthropicModel(model) {
|
|
6
7
|
// Extract model name from face@model format
|
|
7
8
|
const parts = model.split('@');
|
|
@@ -91,18 +92,18 @@ export default class ChatChat extends BaseCommand {
|
|
|
91
92
|
if (flags['oauth-only'])
|
|
92
93
|
payload.oauth_only = true;
|
|
93
94
|
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.
|
|
94
97
|
for await (const line of client.stream('/v1/chat/completions', payload)) {
|
|
95
98
|
if (!line.startsWith('data: '))
|
|
96
99
|
continue;
|
|
97
100
|
const chunk = line.slice(6);
|
|
98
|
-
if (chunk.trim() === '[DONE]')
|
|
99
|
-
|
|
101
|
+
if (chunk.trim() === '[DONE]' || chunk.trim() === '')
|
|
102
|
+
continue;
|
|
100
103
|
try {
|
|
101
|
-
const
|
|
102
|
-
const choices = parsed?.choices;
|
|
103
|
-
const delta = choices?.[0]?.delta?.content ?? '';
|
|
104
|
+
const delta = extractStreamDelta(JSON.parse(chunk));
|
|
104
105
|
if (delta)
|
|
105
|
-
process.stdout.write(
|
|
106
|
+
process.stdout.write(delta);
|
|
106
107
|
}
|
|
107
108
|
catch { /* ignore parse errors */ }
|
|
108
109
|
}
|
|
@@ -149,12 +150,9 @@ export default class ChatChat extends BaseCommand {
|
|
|
149
150
|
if (chunk.trim() === '[DONE]' || chunk.trim() === '')
|
|
150
151
|
continue;
|
|
151
152
|
try {
|
|
152
|
-
const
|
|
153
|
-
if (
|
|
154
|
-
|
|
155
|
-
if (delta)
|
|
156
|
-
process.stdout.write(String(delta));
|
|
157
|
-
}
|
|
153
|
+
const delta = extractStreamDelta(JSON.parse(chunk));
|
|
154
|
+
if (delta)
|
|
155
|
+
process.stdout.write(delta);
|
|
158
156
|
}
|
|
159
157
|
catch { /* ignore */ }
|
|
160
158
|
}
|
|
@@ -195,12 +193,9 @@ export default class ChatChat extends BaseCommand {
|
|
|
195
193
|
if (chunk.trim() === '[DONE]' || chunk.trim() === '')
|
|
196
194
|
continue;
|
|
197
195
|
try {
|
|
198
|
-
const
|
|
199
|
-
if (
|
|
200
|
-
|
|
201
|
-
if (delta)
|
|
202
|
-
process.stdout.write(String(delta));
|
|
203
|
-
}
|
|
196
|
+
const delta = extractStreamDelta(JSON.parse(chunk));
|
|
197
|
+
if (delta)
|
|
198
|
+
process.stdout.write(delta);
|
|
204
199
|
}
|
|
205
200
|
catch { /* ignore */ }
|
|
206
201
|
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { BaseCommand } from '../../base.js';
|
|
2
|
+
export default class ChatThread extends BaseCommand {
|
|
3
|
+
static description: string;
|
|
4
|
+
static examples: string[];
|
|
5
|
+
static flags: {
|
|
6
|
+
id: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
7
|
+
message: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
8
|
+
file: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
9
|
+
llm: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
10
|
+
system: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
11
|
+
'max-tokens': import("@oclif/core/interfaces").OptionFlag<number | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
12
|
+
temperature: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
13
|
+
stream: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
14
|
+
'oauth-only': import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
15
|
+
list: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
16
|
+
show: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
17
|
+
delete: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
18
|
+
'base-url': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
19
|
+
token: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
20
|
+
'api-key': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
21
|
+
};
|
|
22
|
+
static args: {
|
|
23
|
+
face_username: import("@oclif/core/interfaces").Arg<string | undefined, Record<string, unknown>>;
|
|
24
|
+
};
|
|
25
|
+
run(): Promise<unknown>;
|
|
26
|
+
private handleList;
|
|
27
|
+
private handleDelete;
|
|
28
|
+
private handleShow;
|
|
29
|
+
private handleMessage;
|
|
30
|
+
private sendOpenAI;
|
|
31
|
+
private sendAnthropic;
|
|
32
|
+
/**
|
|
33
|
+
* 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.
|
|
36
|
+
*/
|
|
37
|
+
private streamAndAccumulate;
|
|
38
|
+
}
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
import { Args, Flags } from '@oclif/core';
|
|
2
|
+
import crypto from 'node:crypto';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { BaseCommand } from '../../base.js';
|
|
7
|
+
import { FacesAPIError } from '../../client.js';
|
|
8
|
+
import { extractStreamDelta } from '../../utils.js';
|
|
9
|
+
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
|
+
/**
|
|
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
|
|
20
|
+
* Completions.
|
|
21
|
+
*/
|
|
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')) {
|
|
25
|
+
let text = '';
|
|
26
|
+
for (const block of body.content ?? []) {
|
|
27
|
+
if (block.type === 'text')
|
|
28
|
+
text += String(block.text ?? '');
|
|
29
|
+
}
|
|
30
|
+
if (text)
|
|
31
|
+
return text;
|
|
32
|
+
}
|
|
33
|
+
// OpenAI Responses shape: { output: [{content: [{type:'output_text', text}]}] }
|
|
34
|
+
if (routedEndpoint === '/v1/responses' || Array.isArray(body.output)) {
|
|
35
|
+
let text = '';
|
|
36
|
+
for (const item of body.output ?? []) {
|
|
37
|
+
for (const block of item.content ?? []) {
|
|
38
|
+
if (block.type === 'output_text')
|
|
39
|
+
text += String(block.text ?? '');
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
if (text)
|
|
43
|
+
return text;
|
|
44
|
+
}
|
|
45
|
+
// Standard Chat Completions shape: { choices: [{message: {content}}] }
|
|
46
|
+
const choices = body.choices;
|
|
47
|
+
const message = choices?.[0]?.message;
|
|
48
|
+
return String(message?.content ?? '');
|
|
49
|
+
}
|
|
50
|
+
function newThreadId() {
|
|
51
|
+
return 't_' + crypto.randomBytes(6).toString('hex');
|
|
52
|
+
}
|
|
53
|
+
function threadPath(id) {
|
|
54
|
+
return path.join(THREADS_DIR, `${id}.json`);
|
|
55
|
+
}
|
|
56
|
+
function ensureThreadsDir() {
|
|
57
|
+
if (!fs.existsSync(THREADS_DIR))
|
|
58
|
+
fs.mkdirSync(THREADS_DIR, { recursive: true, mode: 0o700 });
|
|
59
|
+
}
|
|
60
|
+
function loadThread(id) {
|
|
61
|
+
const p = threadPath(id);
|
|
62
|
+
if (!fs.existsSync(p))
|
|
63
|
+
return null;
|
|
64
|
+
return JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
65
|
+
}
|
|
66
|
+
function saveThread(t) {
|
|
67
|
+
ensureThreadsDir();
|
|
68
|
+
t.updated_at = new Date().toISOString();
|
|
69
|
+
fs.writeFileSync(threadPath(t.id), JSON.stringify(t, null, 2), { mode: 0o600 });
|
|
70
|
+
}
|
|
71
|
+
function listThreads() {
|
|
72
|
+
if (!fs.existsSync(THREADS_DIR))
|
|
73
|
+
return [];
|
|
74
|
+
const out = [];
|
|
75
|
+
for (const f of fs.readdirSync(THREADS_DIR)) {
|
|
76
|
+
if (!f.endsWith('.json'))
|
|
77
|
+
continue;
|
|
78
|
+
try {
|
|
79
|
+
out.push(JSON.parse(fs.readFileSync(path.join(THREADS_DIR, f), 'utf8')));
|
|
80
|
+
}
|
|
81
|
+
catch { /* skip corrupt */ }
|
|
82
|
+
}
|
|
83
|
+
out.sort((a, b) => (b.updated_at ?? '').localeCompare(a.updated_at ?? ''));
|
|
84
|
+
return out;
|
|
85
|
+
}
|
|
86
|
+
export default class ChatThread extends BaseCommand {
|
|
87
|
+
static description = 'Multi-turn chat thread. Conversation history is stored under ~/.faces/threads/<id>.json — resume any thread later with --id.';
|
|
88
|
+
static examples = [
|
|
89
|
+
'<%= config.bin %> <%= command.id %> socrates -m "What is justice?"',
|
|
90
|
+
'<%= config.bin %> <%= command.id %> --id t_abc123 -m "Say more about that"',
|
|
91
|
+
'<%= config.bin %> <%= command.id %> --list',
|
|
92
|
+
'<%= config.bin %> <%= command.id %> --id t_abc123 --show',
|
|
93
|
+
'<%= config.bin %> <%= command.id %> --id t_abc123 --delete',
|
|
94
|
+
];
|
|
95
|
+
static flags = {
|
|
96
|
+
...BaseCommand.baseFlags,
|
|
97
|
+
id: Flags.string({ description: 'Resume an existing thread by id' }),
|
|
98
|
+
message: Flags.string({ char: 'm', description: 'User message' }),
|
|
99
|
+
file: Flags.string({ description: 'Read message from file' }),
|
|
100
|
+
llm: Flags.string({ description: 'LLM override (only honored when starting a new thread)' }),
|
|
101
|
+
system: Flags.string({ description: 'System prompt (only honored when starting a new thread)' }),
|
|
102
|
+
'max-tokens': Flags.integer({ description: 'Max tokens' }),
|
|
103
|
+
temperature: Flags.string({ description: 'Temperature (0.0-2.0, OpenAI/Chat Completions only)' }),
|
|
104
|
+
stream: Flags.boolean({ description: 'Stream the response', default: false }),
|
|
105
|
+
'oauth-only': Flags.boolean({ description: 'Prevent fallback to paid system key', default: false }),
|
|
106
|
+
list: Flags.boolean({ description: 'List saved threads', default: false }),
|
|
107
|
+
show: Flags.boolean({ description: 'Show the thread transcript (requires --id)', default: false }),
|
|
108
|
+
delete: Flags.boolean({ description: 'Delete the thread (requires --id)', default: false }),
|
|
109
|
+
};
|
|
110
|
+
static args = {
|
|
111
|
+
face_username: Args.string({
|
|
112
|
+
description: 'Face alias (or alias@model) — required when starting a new thread',
|
|
113
|
+
required: false,
|
|
114
|
+
}),
|
|
115
|
+
};
|
|
116
|
+
async run() {
|
|
117
|
+
const { args, flags } = await this.parse(ChatThread);
|
|
118
|
+
if (flags.list)
|
|
119
|
+
return this.handleList();
|
|
120
|
+
if (flags.delete)
|
|
121
|
+
return this.handleDelete(flags.id);
|
|
122
|
+
if (flags.show)
|
|
123
|
+
return this.handleShow(flags.id);
|
|
124
|
+
return this.handleMessage(args.face_username, flags);
|
|
125
|
+
}
|
|
126
|
+
handleList() {
|
|
127
|
+
const threads = listThreads();
|
|
128
|
+
if (this.jsonEnabled()) {
|
|
129
|
+
return threads.map((t) => ({
|
|
130
|
+
id: t.id,
|
|
131
|
+
face: t.face_username,
|
|
132
|
+
model: t.model,
|
|
133
|
+
turns: t.messages.length,
|
|
134
|
+
created_at: t.created_at,
|
|
135
|
+
updated_at: t.updated_at,
|
|
136
|
+
}));
|
|
137
|
+
}
|
|
138
|
+
if (threads.length === 0) {
|
|
139
|
+
this.log('(no threads)');
|
|
140
|
+
return {};
|
|
141
|
+
}
|
|
142
|
+
for (const t of threads) {
|
|
143
|
+
const last = t.messages[t.messages.length - 1];
|
|
144
|
+
const preview = last
|
|
145
|
+
? last.content.slice(0, 60).replace(/\s+/g, ' ') + (last.content.length > 60 ? '…' : '')
|
|
146
|
+
: '';
|
|
147
|
+
this.log(`${t.id} ${t.model} (${t.messages.length} turns, updated ${t.updated_at})`);
|
|
148
|
+
if (preview)
|
|
149
|
+
this.log(` ${last.role}: ${preview}`);
|
|
150
|
+
}
|
|
151
|
+
return {};
|
|
152
|
+
}
|
|
153
|
+
handleDelete(id) {
|
|
154
|
+
if (!id)
|
|
155
|
+
this.error('--delete requires --id <thread-id>');
|
|
156
|
+
const p = threadPath(id);
|
|
157
|
+
if (!fs.existsSync(p))
|
|
158
|
+
this.error(`Thread not found: ${id}`);
|
|
159
|
+
fs.unlinkSync(p);
|
|
160
|
+
if (this.jsonEnabled())
|
|
161
|
+
return { deleted: id };
|
|
162
|
+
this.log(`Deleted thread ${id}`);
|
|
163
|
+
return { deleted: id };
|
|
164
|
+
}
|
|
165
|
+
handleShow(id) {
|
|
166
|
+
if (!id)
|
|
167
|
+
this.error('--show requires --id <thread-id>');
|
|
168
|
+
const t = loadThread(id);
|
|
169
|
+
if (!t)
|
|
170
|
+
this.error(`Thread not found: ${id}`);
|
|
171
|
+
if (this.jsonEnabled())
|
|
172
|
+
return t;
|
|
173
|
+
this.log(`Thread ${t.id} (${t.model}, provider=${t.provider})`);
|
|
174
|
+
this.log(`Created: ${t.created_at}`);
|
|
175
|
+
this.log(`Updated: ${t.updated_at}`);
|
|
176
|
+
if (t.system) {
|
|
177
|
+
this.log('---');
|
|
178
|
+
this.log(`system: ${t.system}`);
|
|
179
|
+
}
|
|
180
|
+
for (const m of t.messages) {
|
|
181
|
+
this.log('---');
|
|
182
|
+
this.log(`${m.role} [${m.ts}]:`);
|
|
183
|
+
this.log(m.content);
|
|
184
|
+
}
|
|
185
|
+
return t;
|
|
186
|
+
}
|
|
187
|
+
async handleMessage(face_username, flags) {
|
|
188
|
+
let userMessage;
|
|
189
|
+
if (flags.file) {
|
|
190
|
+
if (!fs.existsSync(flags.file))
|
|
191
|
+
this.error(`File not found: ${flags.file}`);
|
|
192
|
+
userMessage = fs.readFileSync(flags.file, 'utf8');
|
|
193
|
+
}
|
|
194
|
+
else if (flags.message) {
|
|
195
|
+
userMessage = flags.message;
|
|
196
|
+
}
|
|
197
|
+
else {
|
|
198
|
+
this.error('Provide --message/-m or --file');
|
|
199
|
+
}
|
|
200
|
+
const client = this.makeClient(flags);
|
|
201
|
+
let thread;
|
|
202
|
+
if (flags.id) {
|
|
203
|
+
const existing = loadThread(flags.id);
|
|
204
|
+
if (!existing)
|
|
205
|
+
this.error(`Thread not found: ${flags.id}`);
|
|
206
|
+
thread = existing;
|
|
207
|
+
if (flags.llm || flags.system) {
|
|
208
|
+
this.warn('--llm and --system are ignored when resuming a thread; using values stored on the thread');
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
else {
|
|
212
|
+
if (!face_username) {
|
|
213
|
+
this.error('Provide a face (e.g. `faces chat:thread socrates -m "…"`) or --id <thread-id> to resume');
|
|
214
|
+
}
|
|
215
|
+
const baseAlias = face_username.split('@')[0];
|
|
216
|
+
const model = flags.llm ? `${baseAlias}@${flags.llm}` : face_username;
|
|
217
|
+
const now = new Date().toISOString();
|
|
218
|
+
thread = {
|
|
219
|
+
id: newThreadId(),
|
|
220
|
+
face_username: baseAlias,
|
|
221
|
+
model,
|
|
222
|
+
provider: isAnthropicModel(model) ? 'anthropic' : 'openai',
|
|
223
|
+
system: flags.system,
|
|
224
|
+
created_at: now,
|
|
225
|
+
updated_at: now,
|
|
226
|
+
messages: [],
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
thread.messages.push({ role: 'user', content: userMessage, ts: new Date().toISOString() });
|
|
230
|
+
try {
|
|
231
|
+
const assistantText = thread.provider === 'anthropic'
|
|
232
|
+
? await this.sendAnthropic(client, thread, flags)
|
|
233
|
+
: await this.sendOpenAI(client, thread, flags);
|
|
234
|
+
thread.messages.push({ role: 'assistant', content: assistantText, ts: new Date().toISOString() });
|
|
235
|
+
saveThread(thread);
|
|
236
|
+
if (this.jsonEnabled()) {
|
|
237
|
+
return {
|
|
238
|
+
thread_id: thread.id,
|
|
239
|
+
model: thread.model,
|
|
240
|
+
provider: thread.provider,
|
|
241
|
+
assistant: assistantText,
|
|
242
|
+
turns: thread.messages.length,
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
if (!flags.stream)
|
|
246
|
+
this.log(assistantText);
|
|
247
|
+
this.log(`\n[thread ${thread.id} · ${thread.messages.length} turns]`);
|
|
248
|
+
return { thread_id: thread.id, assistant: assistantText };
|
|
249
|
+
}
|
|
250
|
+
catch (err) {
|
|
251
|
+
if (err instanceof FacesAPIError) {
|
|
252
|
+
if (err.errorCode === 'oauth_rejected') {
|
|
253
|
+
const hint = err.fallbackAvailable
|
|
254
|
+
? '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}`);
|
|
257
|
+
}
|
|
258
|
+
this.error(`Error (${err.statusCode}): ${err.message}`);
|
|
259
|
+
}
|
|
260
|
+
throw err;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
async sendOpenAI(client, thread, flags) {
|
|
264
|
+
const messages = [];
|
|
265
|
+
if (thread.system)
|
|
266
|
+
messages.push({ role: 'system', content: thread.system });
|
|
267
|
+
for (const m of thread.messages)
|
|
268
|
+
messages.push({ role: m.role, content: m.content });
|
|
269
|
+
const payload = { model: thread.model, messages, stream: flags.stream };
|
|
270
|
+
if (flags['max-tokens'])
|
|
271
|
+
payload.max_tokens = flags['max-tokens'];
|
|
272
|
+
if (flags.temperature !== undefined)
|
|
273
|
+
payload.temperature = Number.parseFloat(flags.temperature);
|
|
274
|
+
if (flags['oauth-only'])
|
|
275
|
+
payload.oauth_only = true;
|
|
276
|
+
if (flags.stream) {
|
|
277
|
+
return this.streamAndAccumulate(client, '/v1/chat/completions', payload);
|
|
278
|
+
}
|
|
279
|
+
const { data, headers } = await client.postWithHeaders('/v1/chat/completions', { body: payload });
|
|
280
|
+
return extractAssistantText(data, headers['x-faces-routed-endpoint']);
|
|
281
|
+
}
|
|
282
|
+
async sendAnthropic(client, thread, flags) {
|
|
283
|
+
const msgs = thread.messages.map((m) => ({ role: m.role, content: m.content }));
|
|
284
|
+
const payload = {
|
|
285
|
+
model: thread.model,
|
|
286
|
+
messages: msgs,
|
|
287
|
+
max_tokens: flags['max-tokens'] ?? 1024,
|
|
288
|
+
stream: flags.stream,
|
|
289
|
+
};
|
|
290
|
+
if (thread.system)
|
|
291
|
+
payload.system = thread.system;
|
|
292
|
+
if (flags['oauth-only'])
|
|
293
|
+
payload.oauth_only = true;
|
|
294
|
+
if (flags.stream) {
|
|
295
|
+
return this.streamAndAccumulate(client, '/v1/messages', payload);
|
|
296
|
+
}
|
|
297
|
+
const { data, headers } = await client.postWithHeaders('/v1/messages', { body: payload });
|
|
298
|
+
return extractAssistantText(data, headers['x-faces-routed-endpoint']);
|
|
299
|
+
}
|
|
300
|
+
/**
|
|
301
|
+
* 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.
|
|
304
|
+
*/
|
|
305
|
+
async streamAndAccumulate(client, endpoint, payload) {
|
|
306
|
+
let accum = '';
|
|
307
|
+
for await (const line of client.stream(endpoint, payload)) {
|
|
308
|
+
if (!line.startsWith('data: '))
|
|
309
|
+
continue;
|
|
310
|
+
const chunk = line.slice(6);
|
|
311
|
+
if (chunk.trim() === '[DONE]' || chunk.trim() === '')
|
|
312
|
+
continue;
|
|
313
|
+
try {
|
|
314
|
+
const delta = extractStreamDelta(JSON.parse(chunk));
|
|
315
|
+
if (delta) {
|
|
316
|
+
accum += delta;
|
|
317
|
+
process.stdout.write(delta);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
catch { /* ignore parse errors */ }
|
|
321
|
+
}
|
|
322
|
+
process.stdout.write('\n');
|
|
323
|
+
return accum;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
@@ -7,6 +7,7 @@ export default class KeysCreate extends BaseCommand {
|
|
|
7
7
|
budget: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
8
8
|
face: import("@oclif/core/interfaces").OptionFlag<string[] | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
9
9
|
model: import("@oclif/core/interfaces").OptionFlag<string[] | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
10
|
+
save: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
10
11
|
'base-url': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
11
12
|
token: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
12
13
|
'api-key': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { Flags } from '@oclif/core';
|
|
2
2
|
import { BaseCommand } from '../../base.js';
|
|
3
3
|
import { FacesAPIError } from '../../client.js';
|
|
4
|
+
import { saveConfig } from '../../config.js';
|
|
4
5
|
export default class KeysCreate extends BaseCommand {
|
|
5
|
-
static description = 'Create a new API key (JWT required)';
|
|
6
|
+
static description = 'Create a new API key (JWT required). The new key is saved to ~/.faces/config.json as api_key by default; pass --no-save to skip.';
|
|
6
7
|
static flags = {
|
|
7
8
|
...BaseCommand.baseFlags,
|
|
8
9
|
name: Flags.string({ description: 'Key name/label', required: true }),
|
|
@@ -10,6 +11,11 @@ export default class KeysCreate extends BaseCommand {
|
|
|
10
11
|
budget: Flags.string({ description: 'Spend budget in USD' }),
|
|
11
12
|
face: Flags.string({ description: 'Allowed face alias (repeatable)', multiple: true }),
|
|
12
13
|
model: Flags.string({ description: 'Allowed model name (repeatable)', multiple: true }),
|
|
14
|
+
save: Flags.boolean({
|
|
15
|
+
description: 'Save the new key to ~/.faces/config.json as api_key (default: true; use --no-save to skip)',
|
|
16
|
+
default: true,
|
|
17
|
+
allowNo: true,
|
|
18
|
+
}),
|
|
13
19
|
};
|
|
14
20
|
async run() {
|
|
15
21
|
const { flags } = await this.parse(KeysCreate);
|
|
@@ -25,15 +31,40 @@ export default class KeysCreate extends BaseCommand {
|
|
|
25
31
|
payload.allowed_models = flags.model;
|
|
26
32
|
let data;
|
|
27
33
|
try {
|
|
28
|
-
data = await client.post('/v1/auth/api-keys', {
|
|
34
|
+
data = (await client.post('/v1/auth/api-keys', {
|
|
35
|
+
requireJwt: true,
|
|
36
|
+
body: payload,
|
|
37
|
+
}));
|
|
29
38
|
}
|
|
30
39
|
catch (err) {
|
|
31
40
|
if (err instanceof FacesAPIError)
|
|
32
41
|
this.error(`Error (${err.statusCode}): ${err.message}`);
|
|
33
42
|
throw err;
|
|
34
43
|
}
|
|
35
|
-
|
|
36
|
-
|
|
44
|
+
// The plaintext secret is returned exactly once — here. keys:list only ever
|
|
45
|
+
// returns it truncated, so it must be saved or copied now.
|
|
46
|
+
const apiKey = (data.key ?? data.api_key ?? data.secret);
|
|
47
|
+
let saved = false;
|
|
48
|
+
if (flags.save && apiKey) {
|
|
49
|
+
saveConfig({ api_key: apiKey });
|
|
50
|
+
saved = true;
|
|
51
|
+
}
|
|
52
|
+
if (this.jsonEnabled()) {
|
|
53
|
+
data._saved_to_config = saved;
|
|
54
|
+
return data;
|
|
55
|
+
}
|
|
56
|
+
this.printHuman(data);
|
|
57
|
+
this.log('');
|
|
58
|
+
this.log('⚠ The full key is shown only once — `faces keys:list` returns it truncated by design.');
|
|
59
|
+
if (saved) {
|
|
60
|
+
this.log('✓ Saved to ~/.faces/config.json as api_key — faces commands will use it automatically.');
|
|
61
|
+
}
|
|
62
|
+
else if (!flags.save) {
|
|
63
|
+
this.log(' Not saved (--no-save). Copy the key above now.');
|
|
64
|
+
}
|
|
65
|
+
else {
|
|
66
|
+
this.warn('Could not find the key in the response — nothing was saved. Copy it manually.');
|
|
67
|
+
}
|
|
37
68
|
return data;
|
|
38
69
|
}
|
|
39
70
|
}
|
package/dist/utils.d.ts
CHANGED
|
@@ -10,3 +10,13 @@ export declare const flattenBasicFacts: typeof flattenAttributes;
|
|
|
10
10
|
* Mutates the object in place and returns it.
|
|
11
11
|
*/
|
|
12
12
|
export declare function renameFaceFields(face: Record<string, unknown>): Record<string, unknown>;
|
|
13
|
+
/**
|
|
14
|
+
* Extract a streamed text delta from a parsed SSE event, regardless of which
|
|
15
|
+
* API family produced it. A request posted to /v1/chat/completions may be
|
|
16
|
+
* re-routed by the backend to /v1/messages or /v1/responses, so a stream
|
|
17
|
+
* consumer must handle all three event shapes:
|
|
18
|
+
* - Anthropic Messages: { type: 'content_block_delta', delta: {text} }
|
|
19
|
+
* - OpenAI Responses: { type: 'response.output_text.delta', delta }
|
|
20
|
+
* - OpenAI Chat Completions: { choices: [{delta: {content}}] }
|
|
21
|
+
*/
|
|
22
|
+
export declare function extractStreamDelta(parsed: Record<string, unknown>): string;
|
package/dist/utils.js
CHANGED
|
@@ -29,3 +29,22 @@ export function renameFaceFields(face) {
|
|
|
29
29
|
}
|
|
30
30
|
return face;
|
|
31
31
|
}
|
|
32
|
+
/**
|
|
33
|
+
* Extract a streamed text delta from a parsed SSE event, regardless of which
|
|
34
|
+
* API family produced it. A request posted to /v1/chat/completions may be
|
|
35
|
+
* re-routed by the backend to /v1/messages or /v1/responses, so a stream
|
|
36
|
+
* consumer must handle all three event shapes:
|
|
37
|
+
* - Anthropic Messages: { type: 'content_block_delta', delta: {text} }
|
|
38
|
+
* - OpenAI Responses: { type: 'response.output_text.delta', delta }
|
|
39
|
+
* - OpenAI Chat Completions: { choices: [{delta: {content}}] }
|
|
40
|
+
*/
|
|
41
|
+
export function extractStreamDelta(parsed) {
|
|
42
|
+
if (parsed.type === 'content_block_delta') {
|
|
43
|
+
return String(parsed.delta?.text ?? '');
|
|
44
|
+
}
|
|
45
|
+
if (parsed.type === 'response.output_text.delta') {
|
|
46
|
+
return String(parsed.delta ?? '');
|
|
47
|
+
}
|
|
48
|
+
const choices = parsed.choices;
|
|
49
|
+
return String(choices?.[0]?.delta?.content ?? '');
|
|
50
|
+
}
|