@xfey/tutti 0.1.23 → 0.1.25
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/README.md +1 -1
- package/dist/providers/openai/credential-validation.d.ts +24 -0
- package/dist/providers/openai/credential-validation.js +68 -0
- package/dist/server-shell/cli/launch-command.d.ts +0 -2
- package/dist/server-shell/cli/launch-command.js +45 -48
- package/dist/server-shell/cli/provider-tui.d.ts +5 -1
- package/dist/server-shell/cli/provider-tui.js +163 -23
- package/dist/server-shell/cli/terminal-qr.d.ts +1 -0
- package/dist/server-shell/cli/terminal-qr.js +3 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
- packaged / production `tutti launch` 默认连接 `https://tutti.now`;`TUTTI_RELAY_URL` 可覆盖 Relay URL,本地开发 dev runner 默认使用 `http://127.0.0.1:4370`。
|
|
44
44
|
- host 注册使用 machine-local `host_registration_secret`;该 secret 明文只可写入受限权限的 machine-local secret store,不写入目标项目 repo、Git local config 项目身份、SQLite 协作真相或日志,`binding.json` 只保存引用。
|
|
45
45
|
- Relay registration 成功后只把 `relay_project_ref` 写回 machine-local binding;join URL 只在 host 本机终端或当前 host-local control 内存状态中展示,不写入 binding、runtime endpoint、SQLite 协作真相或日志。
|
|
46
|
-
- 普通 `tutti launch` 默认后台运行 host,CLI 完成页展示 join URL
|
|
46
|
+
- 普通 `tutti launch` 默认后台运行 host,CLI 完成页展示 join URL 后直接退出;`tutti launch --foreground` 保留前台诊断模式。Host HTTP request log 与 Control Plane / Run Pipeline runtime log 写入 `TUTTI_HOME/logs/host.log`,不写入目标项目 repo 或常规 CLI stdout。
|
|
47
47
|
- Reference 和 Skills 上传的最终写入由 Host Project API 完成;Host 只通过 Relay host-control resolve 获取短期 R2 URL 下载 staged object,不接收浏览器 base64 文件正文,也不持久化 presigned URL。
|
|
48
48
|
- Host Project API command 的 browser session context 只信任 Relay tunnel metadata 中的 `relay_session_context`;route handler 不读取浏览器身份 header,也不接受 payload 内身份字段。
|
|
49
49
|
- Fastify request log、debug log、SSE payload、activity event 和 run result 都必须经过 redaction,不得泄露 provider secret、host registration secret、host connection token、join token、cookie、host 绝对路径或 run workspace path。
|
|
@@ -34,6 +34,27 @@ export type OpenAiCredentialValidationClient = {
|
|
|
34
34
|
}) => Promise<unknown>;
|
|
35
35
|
};
|
|
36
36
|
};
|
|
37
|
+
export type OpenAiModelDiscoveryInput = {
|
|
38
|
+
apiKey: string;
|
|
39
|
+
apiBaseUrl?: string;
|
|
40
|
+
organizationId?: string;
|
|
41
|
+
openAiProjectId?: string;
|
|
42
|
+
};
|
|
43
|
+
export type OpenAiModelDiscoveryClient = {
|
|
44
|
+
models: {
|
|
45
|
+
list: (options?: {
|
|
46
|
+
timeout?: number;
|
|
47
|
+
maxRetries?: number;
|
|
48
|
+
}) => Promise<unknown>;
|
|
49
|
+
};
|
|
50
|
+
};
|
|
51
|
+
export type OpenAiModelDiscoveryResult = {
|
|
52
|
+
kind: "ok";
|
|
53
|
+
models: string[];
|
|
54
|
+
} | {
|
|
55
|
+
kind: "unavailable";
|
|
56
|
+
reason: "model_list_unavailable" | "model_list_empty";
|
|
57
|
+
};
|
|
37
58
|
export type ValidateOpenAiCredentialOptions = {
|
|
38
59
|
client?: OpenAiCredentialValidationClient;
|
|
39
60
|
now?: () => Date;
|
|
@@ -42,4 +63,7 @@ export declare function summarizeOpenAiCredentialValidationError(error: unknown,
|
|
|
42
63
|
export declare function classifyOpenAiCredentialValidationError(error: unknown): OpenAiCredentialValidationFailureReason;
|
|
43
64
|
export declare function isOpenAiCredentialValidationFailureRetryable(reason: OpenAiCredentialValidationFailureReason): boolean;
|
|
44
65
|
export declare function validateOpenAiCredential(input: OpenAiCredentialValidationInput, options?: ValidateOpenAiCredentialOptions): Promise<OpenAiCredentialValidationResult>;
|
|
66
|
+
export declare function discoverOpenAiModels(input: OpenAiModelDiscoveryInput, options?: {
|
|
67
|
+
client?: OpenAiModelDiscoveryClient;
|
|
68
|
+
}): Promise<OpenAiModelDiscoveryResult>;
|
|
45
69
|
//# sourceMappingURL=credential-validation.d.ts.map
|
|
@@ -2,6 +2,7 @@ import OpenAI, { APIConnectionError, APIConnectionTimeoutError, APIError, Authen
|
|
|
2
2
|
import { DEFAULT_OPENAI_MODEL } from "./model-config.js";
|
|
3
3
|
const VALIDATION_INPUT = "Tutti provider validation. Reply with the single word ok.";
|
|
4
4
|
const VALIDATION_TIMEOUT_MS = 20_000;
|
|
5
|
+
const MODEL_DISCOVERY_TIMEOUT_MS = 12_000;
|
|
5
6
|
function createOpenAiValidationClient(input) {
|
|
6
7
|
const clientOptions = {
|
|
7
8
|
apiKey: input.apiKey,
|
|
@@ -19,6 +20,47 @@ function createOpenAiValidationClient(input) {
|
|
|
19
20
|
}
|
|
20
21
|
return new OpenAI(clientOptions);
|
|
21
22
|
}
|
|
23
|
+
function createOpenAiModelDiscoveryClient(input) {
|
|
24
|
+
const clientOptions = {
|
|
25
|
+
apiKey: input.apiKey,
|
|
26
|
+
maxRetries: 0,
|
|
27
|
+
timeout: MODEL_DISCOVERY_TIMEOUT_MS,
|
|
28
|
+
};
|
|
29
|
+
if (input.apiBaseUrl !== undefined) {
|
|
30
|
+
clientOptions.baseURL = input.apiBaseUrl;
|
|
31
|
+
}
|
|
32
|
+
if (input.organizationId !== undefined) {
|
|
33
|
+
clientOptions.organization = input.organizationId;
|
|
34
|
+
}
|
|
35
|
+
if (input.openAiProjectId !== undefined) {
|
|
36
|
+
clientOptions.project = input.openAiProjectId;
|
|
37
|
+
}
|
|
38
|
+
return new OpenAI(clientOptions);
|
|
39
|
+
}
|
|
40
|
+
function isRecord(value) {
|
|
41
|
+
return typeof value === "object" && value !== null;
|
|
42
|
+
}
|
|
43
|
+
function modelIdFromItem(item) {
|
|
44
|
+
if (!isRecord(item)) {
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
const id = item.id;
|
|
48
|
+
if (typeof id === "string" && id.trim() !== "") {
|
|
49
|
+
return id.trim();
|
|
50
|
+
}
|
|
51
|
+
const name = item.name;
|
|
52
|
+
if (typeof name === "string" && name.trim() !== "") {
|
|
53
|
+
return name.trim();
|
|
54
|
+
}
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
function modelIdsFromResponse(response) {
|
|
58
|
+
if (!isRecord(response) || !Array.isArray(response.data)) {
|
|
59
|
+
return [];
|
|
60
|
+
}
|
|
61
|
+
return [...new Set(response.data.map(modelIdFromItem).filter((model) => model !== undefined))]
|
|
62
|
+
.sort((left, right) => left.localeCompare(right));
|
|
63
|
+
}
|
|
22
64
|
function lowerErrorText(error) {
|
|
23
65
|
if (error instanceof Error) {
|
|
24
66
|
return error.message.toLowerCase();
|
|
@@ -187,4 +229,30 @@ export async function validateOpenAiCredential(input, options = {}) {
|
|
|
187
229
|
};
|
|
188
230
|
}
|
|
189
231
|
}
|
|
232
|
+
export async function discoverOpenAiModels(input, options = {}) {
|
|
233
|
+
const client = options.client ?? createOpenAiModelDiscoveryClient(input);
|
|
234
|
+
try {
|
|
235
|
+
const response = await client.models.list({
|
|
236
|
+
timeout: MODEL_DISCOVERY_TIMEOUT_MS,
|
|
237
|
+
maxRetries: 0,
|
|
238
|
+
});
|
|
239
|
+
const models = modelIdsFromResponse(response);
|
|
240
|
+
if (models.length === 0) {
|
|
241
|
+
return {
|
|
242
|
+
kind: "unavailable",
|
|
243
|
+
reason: "model_list_empty",
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
return {
|
|
247
|
+
kind: "ok",
|
|
248
|
+
models,
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
catch {
|
|
252
|
+
return {
|
|
253
|
+
kind: "unavailable",
|
|
254
|
+
reason: "model_list_unavailable",
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
}
|
|
190
258
|
//# sourceMappingURL=credential-validation.js.map
|
|
@@ -4,7 +4,7 @@ import { formatLaunchLifecycleResult, startLaunchProject, waitForForegroundHostS
|
|
|
4
4
|
import { prepareLaunchProject } from "./launch.js";
|
|
5
5
|
import { spawnDetachedHost, waitForManagedHostReady } from "./managed-host.js";
|
|
6
6
|
import { resolveLaunchWorkspacePath } from "./project-resolver.js";
|
|
7
|
-
import { formatJoinLinkValidity, formatTerminalLink
|
|
7
|
+
import { formatJoinLinkValidity, formatTerminalLink } from "./terminal-qr.js";
|
|
8
8
|
import { renderTuttiTerminalLogo } from "./terminal-logo.js";
|
|
9
9
|
import { runProviderSetupTui } from "./provider-tui.js";
|
|
10
10
|
import { LaunchError } from "./errors.js";
|
|
@@ -25,69 +25,69 @@ export async function confirmFromTty(request) {
|
|
|
25
25
|
readline.close();
|
|
26
26
|
}
|
|
27
27
|
}
|
|
28
|
-
async function waitForEnter() {
|
|
29
|
-
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
30
|
-
return;
|
|
31
|
-
}
|
|
32
|
-
const readline = createInterface({
|
|
33
|
-
input: process.stdin,
|
|
34
|
-
output: process.stdout,
|
|
35
|
-
});
|
|
36
|
-
try {
|
|
37
|
-
await readline.question("");
|
|
38
|
-
}
|
|
39
|
-
finally {
|
|
40
|
-
readline.close();
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
28
|
function formatProjectLabel(workspaceRoot, projectId) {
|
|
44
29
|
return basename(workspaceRoot) || projectId;
|
|
45
30
|
}
|
|
31
|
+
function visibleTerminalLength(line) {
|
|
32
|
+
let stripped = line;
|
|
33
|
+
while (true) {
|
|
34
|
+
const start = stripped.indexOf("\u001B]8;;");
|
|
35
|
+
if (start < 0) {
|
|
36
|
+
break;
|
|
37
|
+
}
|
|
38
|
+
const end = stripped.indexOf("\u001B\\", start);
|
|
39
|
+
if (end < 0) {
|
|
40
|
+
break;
|
|
41
|
+
}
|
|
42
|
+
stripped = `${stripped.slice(0, start)}${stripped.slice(end + 2)}`;
|
|
43
|
+
}
|
|
44
|
+
while (true) {
|
|
45
|
+
const start = stripped.indexOf("\u001B[");
|
|
46
|
+
if (start < 0) {
|
|
47
|
+
break;
|
|
48
|
+
}
|
|
49
|
+
const end = Array.from(stripped.slice(start + 2)).findIndex((character) => {
|
|
50
|
+
const codePoint = character.codePointAt(0);
|
|
51
|
+
return (codePoint !== undefined &&
|
|
52
|
+
((codePoint >= 0x41 && codePoint <= 0x5a) ||
|
|
53
|
+
(codePoint >= 0x61 && codePoint <= 0x7a)));
|
|
54
|
+
});
|
|
55
|
+
if (end < 0) {
|
|
56
|
+
break;
|
|
57
|
+
}
|
|
58
|
+
stripped = `${stripped.slice(0, start)}${stripped.slice(start + 3 + end)}`;
|
|
59
|
+
}
|
|
60
|
+
return stripped.length;
|
|
61
|
+
}
|
|
46
62
|
function borderedBlock(lines) {
|
|
47
|
-
const width = Math.max(...lines.map(
|
|
63
|
+
const width = Math.max(...lines.map(visibleTerminalLength));
|
|
48
64
|
const top = `╭${"─".repeat(width + 2)}╮`;
|
|
49
65
|
const bottom = `╰${"─".repeat(width + 2)}╯`;
|
|
50
|
-
const body = lines.map((line) => `│ ${line.
|
|
66
|
+
const body = lines.map((line) => `│ ${line}${" ".repeat(width - visibleTerminalLength(line))} │`);
|
|
51
67
|
return [top, ...body, bottom].join("\n");
|
|
52
68
|
}
|
|
53
69
|
export function renderCompletionPage(options) {
|
|
54
70
|
const isTty = options.tty ?? process.stdout.isTTY === true;
|
|
55
71
|
const projectLabel = formatProjectLabel(options.workspaceRoot, options.projectId);
|
|
56
72
|
const joinUrl = formatTerminalLink(options.joinUrl, { hyperlinks: options.hyperlinks === true });
|
|
73
|
+
const joinValidity = formatJoinLinkValidity({
|
|
74
|
+
...(options.joinTokenExpiresAt === undefined
|
|
75
|
+
? {}
|
|
76
|
+
: { expiresAt: options.joinTokenExpiresAt }),
|
|
77
|
+
...(options.joinTokenReusable === undefined
|
|
78
|
+
? {}
|
|
79
|
+
: { reusable: options.joinTokenReusable }),
|
|
80
|
+
includeRemaining: false,
|
|
81
|
+
});
|
|
57
82
|
const summaryLines = [
|
|
58
83
|
"Tutti is hosting this project.",
|
|
59
84
|
`Project: ${projectLabel}`,
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
`Relay: ${options.relayUrl}`,
|
|
63
|
-
];
|
|
64
|
-
const nextSteps = [
|
|
65
|
-
"Next:",
|
|
66
|
-
" Open the join URL or scan the QR code.",
|
|
67
|
-
" `tutti invite` rotates a fresh link.",
|
|
68
|
-
" `tutti ps` shows running hosts.",
|
|
69
|
-
" `tutti stop` stops this host.",
|
|
85
|
+
`Join URL: ${joinUrl}`,
|
|
86
|
+
joinValidity,
|
|
70
87
|
];
|
|
71
88
|
return [
|
|
72
89
|
...(isTty ? [CLEAR, renderTuttiTerminalLogo({ tty: true }), ""] : []),
|
|
73
90
|
isTty ? borderedBlock(summaryLines) : summaryLines.join("\n"),
|
|
74
|
-
"",
|
|
75
|
-
`Join URL: ${joinUrl}`,
|
|
76
|
-
formatJoinLinkValidity({
|
|
77
|
-
...(options.joinTokenExpiresAt === undefined
|
|
78
|
-
? {}
|
|
79
|
-
: { expiresAt: options.joinTokenExpiresAt }),
|
|
80
|
-
...(options.joinTokenReusable === undefined
|
|
81
|
-
? {}
|
|
82
|
-
: { reusable: options.joinTokenReusable }),
|
|
83
|
-
}),
|
|
84
|
-
"",
|
|
85
|
-
"QR code:",
|
|
86
|
-
renderTerminalQr(options.joinUrl),
|
|
87
|
-
"",
|
|
88
|
-
...nextSteps,
|
|
89
|
-
"",
|
|
90
|
-
isTty ? "[Enter] close this view. The host keeps running." : "The host keeps running.",
|
|
91
91
|
].join("\n");
|
|
92
92
|
}
|
|
93
93
|
async function ensureProviderConfigured(preparation) {
|
|
@@ -150,8 +150,6 @@ export async function runBackgroundLaunchCommand(options) {
|
|
|
150
150
|
joinUrl: ready.join_url,
|
|
151
151
|
projectId: preparation.project_id,
|
|
152
152
|
workspaceRoot: preparation.workspace_root,
|
|
153
|
-
relayUrl: preparation.relay_url,
|
|
154
|
-
branch: preparation.branch,
|
|
155
153
|
tty: process.stdout.isTTY === true,
|
|
156
154
|
hyperlinks: process.stdout.isTTY === true,
|
|
157
155
|
...(ready.join_token_expires_at === undefined
|
|
@@ -161,6 +159,5 @@ export async function runBackgroundLaunchCommand(options) {
|
|
|
161
159
|
? {}
|
|
162
160
|
: { joinTokenReusable: ready.join_token_reusable }),
|
|
163
161
|
})}\n`);
|
|
164
|
-
await waitForEnter();
|
|
165
162
|
}
|
|
166
163
|
//# sourceMappingURL=launch-command.js.map
|
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
import { type ConfigureProjectOpenAiProviderResult } from "../../providers/openai/index.js";
|
|
2
2
|
import type { ProjectId } from "@tutti/shared/ids";
|
|
3
|
-
type ProviderSetupStep = "base-url" | "api-key";
|
|
3
|
+
type ProviderSetupStep = "base-url" | "api-key" | "model";
|
|
4
4
|
type ProviderFormState = {
|
|
5
5
|
step: ProviderSetupStep;
|
|
6
6
|
baseUrl: string;
|
|
7
7
|
apiKey: string;
|
|
8
|
+
modelOptions?: string[];
|
|
9
|
+
modelListStatus?: "loaded" | "unavailable";
|
|
10
|
+
selectedModelIndex?: number;
|
|
11
|
+
customModel?: string;
|
|
8
12
|
error: string | undefined;
|
|
9
13
|
};
|
|
10
14
|
type ProviderSetupLogEvent = {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { emitKeypressEvents } from "node:readline";
|
|
2
2
|
import { redactError } from "@tutti/shared/utils";
|
|
3
|
-
import { configureProjectOpenAiProvider, DEFAULT_OPENAI_MODEL, } from "../../providers/openai/index.js";
|
|
3
|
+
import { configureProjectOpenAiProvider, DEFAULT_OPENAI_MODEL, discoverOpenAiModels, } from "../../providers/openai/index.js";
|
|
4
4
|
import { LaunchError } from "./errors.js";
|
|
5
5
|
import { appendHostLogLine, getHostLogFilePath } from "./machine-local.js";
|
|
6
6
|
import { renderTuttiTerminalLogo } from "./terminal-logo.js";
|
|
@@ -9,41 +9,100 @@ const SHOW_CURSOR = "\u001B[?25h";
|
|
|
9
9
|
const BLINK_ON = "\u001B[5m";
|
|
10
10
|
const BLINK_OFF = "\u001B[25m";
|
|
11
11
|
const CLEAR = "\u001B[2J\u001B[H";
|
|
12
|
-
const
|
|
12
|
+
const FIELD_BOX_WIDTH = 60;
|
|
13
|
+
const MODEL_LIST_WINDOW = 8;
|
|
13
14
|
function providerSetupCancelled() {
|
|
14
15
|
return new LaunchError("provider_setup_cancelled", "Provider setup cancelled.", "Run the command again when you are ready.");
|
|
15
16
|
}
|
|
16
17
|
function renderFieldInput(options) {
|
|
17
18
|
const value = options.secret && options.value.length > 0 ? "*".repeat(options.value.length) : options.value;
|
|
18
|
-
|
|
19
|
+
const visibleValue = value.length >= FIELD_BOX_WIDTH ? `<${value.slice(-(FIELD_BOX_WIDTH - 2))}` : value;
|
|
20
|
+
const cursor = `${BLINK_ON}_${BLINK_OFF}`;
|
|
21
|
+
const plainLength = visibleValue.length + 1;
|
|
22
|
+
const padding = Math.max(0, FIELD_BOX_WIDTH - plainLength);
|
|
23
|
+
return [
|
|
24
|
+
`+${"-".repeat(FIELD_BOX_WIDTH + 2)}+`,
|
|
25
|
+
`| ${visibleValue}${cursor}${" ".repeat(padding)} |`,
|
|
26
|
+
`+${"-".repeat(FIELD_BOX_WIDTH + 2)}+`,
|
|
27
|
+
];
|
|
28
|
+
}
|
|
29
|
+
function modelOptionsWithCustom(state) {
|
|
30
|
+
return [...(state.modelOptions ?? []), ""];
|
|
31
|
+
}
|
|
32
|
+
function selectedModelIndex(state) {
|
|
33
|
+
const options = modelOptionsWithCustom(state);
|
|
34
|
+
const selected = state.selectedModelIndex ?? 0;
|
|
35
|
+
return Math.min(Math.max(0, selected), Math.max(0, options.length - 1));
|
|
36
|
+
}
|
|
37
|
+
function selectedModelValue(state) {
|
|
38
|
+
const options = modelOptionsWithCustom(state);
|
|
39
|
+
const selected = selectedModelIndex(state);
|
|
40
|
+
if (selected < (state.modelOptions ?? []).length) {
|
|
41
|
+
return options[selected] ?? "";
|
|
42
|
+
}
|
|
43
|
+
return state.customModel?.trim() ?? "";
|
|
44
|
+
}
|
|
45
|
+
function renderModelOptions(state) {
|
|
46
|
+
const options = modelOptionsWithCustom(state);
|
|
47
|
+
const selected = selectedModelIndex(state);
|
|
48
|
+
const modelCount = state.modelOptions?.length ?? 0;
|
|
49
|
+
const windowStart = Math.max(0, Math.min(selected - Math.floor(MODEL_LIST_WINDOW / 2), options.length - MODEL_LIST_WINDOW));
|
|
50
|
+
const windowEnd = Math.min(options.length, windowStart + MODEL_LIST_WINDOW);
|
|
51
|
+
const lines = [];
|
|
52
|
+
if (windowStart > 0) {
|
|
53
|
+
lines.push(" ...");
|
|
54
|
+
}
|
|
55
|
+
for (let index = windowStart; index < windowEnd; index += 1) {
|
|
56
|
+
const marker = index === selected ? ">" : " ";
|
|
57
|
+
const label = index < modelCount ? options[index] : "Custom model";
|
|
58
|
+
const suffix = index < modelCount ? "" : " (type your own)";
|
|
59
|
+
lines.push(`${marker} ${label}${suffix}`);
|
|
60
|
+
}
|
|
61
|
+
if (windowEnd < options.length) {
|
|
62
|
+
lines.push(" ...");
|
|
63
|
+
}
|
|
64
|
+
if (selected === modelCount) {
|
|
65
|
+
lines.push("", ...renderFieldInput({ value: state.customModel ?? "" }));
|
|
66
|
+
}
|
|
67
|
+
return lines;
|
|
19
68
|
}
|
|
20
69
|
function renderProviderField(state) {
|
|
21
70
|
if (state.step === "base-url") {
|
|
22
71
|
return [
|
|
23
|
-
"Step 1/
|
|
72
|
+
"Step 1/3 Base URL",
|
|
24
73
|
"OpenAI-compatible Responses API base URL; must expose POST /responses.",
|
|
25
74
|
"",
|
|
26
75
|
...renderFieldInput({
|
|
27
|
-
label: "Base URL",
|
|
28
76
|
value: state.baseUrl,
|
|
29
77
|
}),
|
|
30
78
|
"",
|
|
31
79
|
"[Enter] Continue [Esc] Cancel",
|
|
32
80
|
];
|
|
33
81
|
}
|
|
82
|
+
if (state.step === "api-key") {
|
|
83
|
+
return [
|
|
84
|
+
"Step 2/3 API Key",
|
|
85
|
+
"Stored only in the machine-local Tutti credential store.",
|
|
86
|
+
"",
|
|
87
|
+
...renderFieldInput({
|
|
88
|
+
value: state.apiKey,
|
|
89
|
+
secret: true,
|
|
90
|
+
}),
|
|
91
|
+
"",
|
|
92
|
+
"[Enter] Continue [Esc] Back to Base URL",
|
|
93
|
+
];
|
|
94
|
+
}
|
|
34
95
|
return [
|
|
35
|
-
"Step
|
|
36
|
-
|
|
96
|
+
"Step 3/3 Model",
|
|
97
|
+
state.modelListStatus === "loaded"
|
|
98
|
+
? "Choose a model with Up/Down, or move to Custom model and type one."
|
|
99
|
+
: "Could not read the model list. Type a model name in Custom model.",
|
|
37
100
|
"",
|
|
38
|
-
...
|
|
39
|
-
label: "API Key",
|
|
40
|
-
value: state.apiKey,
|
|
41
|
-
secret: true,
|
|
42
|
-
}),
|
|
101
|
+
...renderModelOptions(state),
|
|
43
102
|
"",
|
|
44
103
|
state.error === undefined
|
|
45
|
-
? "[Enter] Validate [Esc] Back to
|
|
46
|
-
: "[Enter] Retry [Esc] Back to
|
|
104
|
+
? "[Enter] Validate [Esc] Back to API Key"
|
|
105
|
+
: "[Enter] Retry [Esc] Back to API Key",
|
|
47
106
|
];
|
|
48
107
|
}
|
|
49
108
|
export function renderProviderForm(options) {
|
|
@@ -116,6 +175,7 @@ async function readProviderForm(options) {
|
|
|
116
175
|
error: options.initialError,
|
|
117
176
|
};
|
|
118
177
|
return await new Promise((resolve, reject) => {
|
|
178
|
+
let busy = false;
|
|
119
179
|
const render = () => {
|
|
120
180
|
options.stdout.write(renderProviderForm({
|
|
121
181
|
state,
|
|
@@ -129,7 +189,38 @@ async function readProviderForm(options) {
|
|
|
129
189
|
}
|
|
130
190
|
options.stdout.write(SHOW_CURSOR);
|
|
131
191
|
};
|
|
192
|
+
const openModelStep = async () => {
|
|
193
|
+
busy = true;
|
|
194
|
+
state.error = undefined;
|
|
195
|
+
options.stdout.write(renderModelDiscovery(options.stdout.isTTY === true));
|
|
196
|
+
const discoverModels = options.discoverModels ?? discoverOpenAiModels;
|
|
197
|
+
const result = await discoverModels({
|
|
198
|
+
apiKey: state.apiKey.trim(),
|
|
199
|
+
apiBaseUrl: state.baseUrl.trim(),
|
|
200
|
+
}).catch(() => ({
|
|
201
|
+
kind: "unavailable",
|
|
202
|
+
reason: "model_list_unavailable",
|
|
203
|
+
}));
|
|
204
|
+
if (result.kind === "ok") {
|
|
205
|
+
state.modelOptions = result.models;
|
|
206
|
+
state.modelListStatus = "loaded";
|
|
207
|
+
const defaultIndex = result.models.indexOf(DEFAULT_OPENAI_MODEL);
|
|
208
|
+
state.selectedModelIndex = defaultIndex >= 0 ? defaultIndex : 0;
|
|
209
|
+
}
|
|
210
|
+
else {
|
|
211
|
+
state.modelOptions = [];
|
|
212
|
+
state.modelListStatus = "unavailable";
|
|
213
|
+
state.selectedModelIndex = 0;
|
|
214
|
+
}
|
|
215
|
+
state.customModel = "";
|
|
216
|
+
state.step = "model";
|
|
217
|
+
busy = false;
|
|
218
|
+
render();
|
|
219
|
+
};
|
|
132
220
|
const onKeypress = (character, key) => {
|
|
221
|
+
if (busy) {
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
133
224
|
if (key.ctrl === true && key.name === "c") {
|
|
134
225
|
cleanup();
|
|
135
226
|
reject(providerSetupCancelled());
|
|
@@ -142,6 +233,12 @@ async function readProviderForm(options) {
|
|
|
142
233
|
render();
|
|
143
234
|
return;
|
|
144
235
|
}
|
|
236
|
+
if (state.step === "model") {
|
|
237
|
+
state.step = "api-key";
|
|
238
|
+
state.error = undefined;
|
|
239
|
+
render();
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
145
242
|
cleanup();
|
|
146
243
|
reject(providerSetupCancelled());
|
|
147
244
|
return;
|
|
@@ -158,22 +255,48 @@ async function readProviderForm(options) {
|
|
|
158
255
|
render();
|
|
159
256
|
return;
|
|
160
257
|
}
|
|
161
|
-
if (state.
|
|
162
|
-
state.
|
|
258
|
+
if (state.step === "api-key") {
|
|
259
|
+
if (state.apiKey.trim() === "") {
|
|
260
|
+
state.error = "API key is required.";
|
|
261
|
+
render();
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
void openModelStep();
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
const model = selectedModelValue(state);
|
|
268
|
+
if (model === "") {
|
|
269
|
+
state.error = "Model name is required.";
|
|
163
270
|
render();
|
|
164
271
|
return;
|
|
165
272
|
}
|
|
166
273
|
cleanup();
|
|
167
|
-
resolve({
|
|
274
|
+
resolve({
|
|
275
|
+
baseUrl: state.baseUrl.trim(),
|
|
276
|
+
apiKey: state.apiKey.trim(),
|
|
277
|
+
defaultModel: model,
|
|
278
|
+
});
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
if (state.step === "model" && (key.name === "up" || key.name === "down")) {
|
|
282
|
+
const optionsWithCustom = modelOptionsWithCustom(state);
|
|
283
|
+
const delta = key.name === "up" ? -1 : 1;
|
|
284
|
+
state.selectedModelIndex =
|
|
285
|
+
(selectedModelIndex(state) + delta + optionsWithCustom.length) % optionsWithCustom.length;
|
|
286
|
+
state.error = undefined;
|
|
287
|
+
render();
|
|
168
288
|
return;
|
|
169
289
|
}
|
|
170
290
|
if (key.name === "backspace") {
|
|
171
291
|
if (state.step === "base-url") {
|
|
172
292
|
state.baseUrl = state.baseUrl.slice(0, -1);
|
|
173
293
|
}
|
|
174
|
-
else {
|
|
294
|
+
else if (state.step === "api-key") {
|
|
175
295
|
state.apiKey = state.apiKey.slice(0, -1);
|
|
176
296
|
}
|
|
297
|
+
else if (selectedModelIndex(state) === (state.modelOptions?.length ?? 0)) {
|
|
298
|
+
state.customModel = (state.customModel ?? "").slice(0, -1);
|
|
299
|
+
}
|
|
177
300
|
state.error = undefined;
|
|
178
301
|
render();
|
|
179
302
|
return;
|
|
@@ -182,9 +305,13 @@ async function readProviderForm(options) {
|
|
|
182
305
|
if (state.step === "base-url") {
|
|
183
306
|
state.baseUrl += character;
|
|
184
307
|
}
|
|
185
|
-
else {
|
|
308
|
+
else if (state.step === "api-key") {
|
|
186
309
|
state.apiKey += character;
|
|
187
310
|
}
|
|
311
|
+
else {
|
|
312
|
+
state.selectedModelIndex = state.modelOptions?.length ?? 0;
|
|
313
|
+
state.customModel = `${state.customModel ?? ""}${character}`;
|
|
314
|
+
}
|
|
188
315
|
state.error = undefined;
|
|
189
316
|
render();
|
|
190
317
|
}
|
|
@@ -197,7 +324,19 @@ async function readProviderForm(options) {
|
|
|
197
324
|
render();
|
|
198
325
|
});
|
|
199
326
|
}
|
|
200
|
-
function
|
|
327
|
+
function renderModelDiscovery(tty) {
|
|
328
|
+
return [
|
|
329
|
+
CLEAR,
|
|
330
|
+
HIDE_CURSOR,
|
|
331
|
+
renderTuttiTerminalLogo({ tty }),
|
|
332
|
+
"",
|
|
333
|
+
"Provider setup",
|
|
334
|
+
"Looking up available models for this provider.",
|
|
335
|
+
"",
|
|
336
|
+
"Checking model list...",
|
|
337
|
+
].join("\n");
|
|
338
|
+
}
|
|
339
|
+
function renderChecking(frame, tty, model) {
|
|
201
340
|
const frames = ["|", "/", "-", "\\"];
|
|
202
341
|
const indicator = frames[frame % frames.length] ?? "|";
|
|
203
342
|
return [
|
|
@@ -208,7 +347,7 @@ function renderChecking(frame, tty) {
|
|
|
208
347
|
"Provider setup",
|
|
209
348
|
"Validating the configured Responses API endpoint.",
|
|
210
349
|
"",
|
|
211
|
-
`${indicator} Checking provider connection with ${
|
|
350
|
+
`${indicator} Checking provider connection with ${model}...`,
|
|
212
351
|
].join("\n");
|
|
213
352
|
}
|
|
214
353
|
export async function runProviderSetupTui(options) {
|
|
@@ -235,16 +374,17 @@ export async function runProviderSetupTui(options) {
|
|
|
235
374
|
initialStep = "api-key";
|
|
236
375
|
let frame = 0;
|
|
237
376
|
const interval = setInterval(() => {
|
|
238
|
-
stdout.write(renderChecking(frame, stdout.isTTY === true));
|
|
377
|
+
stdout.write(renderChecking(frame, stdout.isTTY === true, input.defaultModel));
|
|
239
378
|
frame += 1;
|
|
240
379
|
}, 120);
|
|
241
380
|
try {
|
|
242
|
-
stdout.write(renderChecking(frame, stdout.isTTY === true));
|
|
381
|
+
stdout.write(renderChecking(frame, stdout.isTTY === true, input.defaultModel));
|
|
243
382
|
const result = await configureProjectOpenAiProvider({
|
|
244
383
|
tuttiHome: options.tuttiHome,
|
|
245
384
|
projectId: options.projectId,
|
|
246
385
|
apiBaseUrl: input.baseUrl,
|
|
247
386
|
apiKey: input.apiKey,
|
|
387
|
+
defaultModel: input.defaultModel,
|
|
248
388
|
});
|
|
249
389
|
clearInterval(interval);
|
|
250
390
|
stdout.write(SHOW_CURSOR);
|
|
@@ -29,6 +29,9 @@ export function formatJoinLinkValidity(options) {
|
|
|
29
29
|
if (options.expiresAt !== undefined) {
|
|
30
30
|
const expiresAt = new Date(options.expiresAt);
|
|
31
31
|
if (!Number.isNaN(expiresAt.getTime())) {
|
|
32
|
+
if (options.includeRemaining === false) {
|
|
33
|
+
return `Join link valid until: ${options.expiresAt}`;
|
|
34
|
+
}
|
|
32
35
|
const now = options.now ?? new Date();
|
|
33
36
|
const remainingMs = expiresAt.getTime() - now.getTime();
|
|
34
37
|
const suffix = remainingMs <= 0
|