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.
- package/dist/commands/catalog/doctor.js +17 -2
- package/dist/commands/chat/chat.d.ts +3 -1
- package/dist/commands/chat/chat.js +60 -118
- package/dist/commands/chat/thread.d.ts +4 -3
- package/dist/commands/chat/thread.js +62 -37
- package/dist/routing.d.ts +19 -0
- package/dist/routing.js +116 -0
- package/oclif.manifest.json +552 -552
- 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,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: '
|
|
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
|
|
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
|
}
|
|
@@ -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');
|
|
@@ -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 = '';
|
|
@@ -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>;
|
package/dist/routing.js
ADDED
|
@@ -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
|
+
}
|