@xfey/tutti 0.1.24 → 0.1.26

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 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 和二维码;`tutti launch --foreground` 保留前台诊断模式。Host HTTP request log 与 Control Plane / Run Pipeline runtime log 写入 `TUTTI_HOME/logs/host.log`,不写入目标项目 repo 或常规 CLI stdout。
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
@@ -91,6 +91,22 @@ function helpForTopic(topic) {
91
91
  }
92
92
  return HELP_TEXT;
93
93
  }
94
+ function restoreInteractiveInput() {
95
+ if (!process.stdin.isTTY) {
96
+ return;
97
+ }
98
+ if (process.stdin.isRaw === true) {
99
+ process.stdin.setRawMode(false);
100
+ }
101
+ process.stdin.removeAllListeners("keypress");
102
+ process.stdin.pause();
103
+ }
104
+ function exitAfterBackgroundLaunch() {
105
+ restoreInteractiveInput();
106
+ process.stdout.write("\n", () => {
107
+ process.exit(0);
108
+ });
109
+ }
94
110
  async function runProviderSetupCommand(target) {
95
111
  const project = resolveExistingProjectContext({
96
112
  ...(target === undefined ? {} : { target }),
@@ -123,6 +139,7 @@ try {
123
139
  }
124
140
  else {
125
141
  await runBackgroundLaunchCommand(command);
142
+ exitAfterBackgroundLaunch();
126
143
  }
127
144
  break;
128
145
  case "internal-host-run":
@@ -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";
@@ -10,6 +10,7 @@ const BLINK_ON = "\u001B[5m";
10
10
  const BLINK_OFF = "\u001B[25m";
11
11
  const CLEAR = "\u001B[2J\u001B[H";
12
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
  }
@@ -25,10 +26,50 @@ function renderFieldInput(options) {
25
26
  `+${"-".repeat(FIELD_BOX_WIDTH + 2)}+`,
26
27
  ];
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;
68
+ }
28
69
  function renderProviderField(state) {
29
70
  if (state.step === "base-url") {
30
71
  return [
31
- "Step 1/2 Base URL",
72
+ "Step 1/3 Base URL",
32
73
  "OpenAI-compatible Responses API base URL; must expose POST /responses.",
33
74
  "",
34
75
  ...renderFieldInput({
@@ -38,18 +79,30 @@ function renderProviderField(state) {
38
79
  "[Enter] Continue [Esc] Cancel",
39
80
  ];
40
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
+ }
41
95
  return [
42
- "Step 2/2 API Key",
43
- "Stored only in the machine-local Tutti credential store.",
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.",
44
100
  "",
45
- ...renderFieldInput({
46
- value: state.apiKey,
47
- secret: true,
48
- }),
101
+ ...renderModelOptions(state),
49
102
  "",
50
103
  state.error === undefined
51
- ? "[Enter] Validate [Esc] Back to Base URL"
52
- : "[Enter] Retry [Esc] Back to Base URL",
104
+ ? "[Enter] Validate [Esc] Back to API Key"
105
+ : "[Enter] Retry [Esc] Back to API Key",
53
106
  ];
54
107
  }
55
108
  export function renderProviderForm(options) {
@@ -122,6 +175,7 @@ async function readProviderForm(options) {
122
175
  error: options.initialError,
123
176
  };
124
177
  return await new Promise((resolve, reject) => {
178
+ let busy = false;
125
179
  const render = () => {
126
180
  options.stdout.write(renderProviderForm({
127
181
  state,
@@ -135,7 +189,38 @@ async function readProviderForm(options) {
135
189
  }
136
190
  options.stdout.write(SHOW_CURSOR);
137
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
+ };
138
220
  const onKeypress = (character, key) => {
221
+ if (busy) {
222
+ return;
223
+ }
139
224
  if (key.ctrl === true && key.name === "c") {
140
225
  cleanup();
141
226
  reject(providerSetupCancelled());
@@ -148,6 +233,12 @@ async function readProviderForm(options) {
148
233
  render();
149
234
  return;
150
235
  }
236
+ if (state.step === "model") {
237
+ state.step = "api-key";
238
+ state.error = undefined;
239
+ render();
240
+ return;
241
+ }
151
242
  cleanup();
152
243
  reject(providerSetupCancelled());
153
244
  return;
@@ -164,22 +255,48 @@ async function readProviderForm(options) {
164
255
  render();
165
256
  return;
166
257
  }
167
- if (state.apiKey.trim() === "") {
168
- state.error = "API key is required.";
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.";
169
270
  render();
170
271
  return;
171
272
  }
172
273
  cleanup();
173
- resolve({ baseUrl: state.baseUrl.trim(), apiKey: state.apiKey.trim() });
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();
174
288
  return;
175
289
  }
176
290
  if (key.name === "backspace") {
177
291
  if (state.step === "base-url") {
178
292
  state.baseUrl = state.baseUrl.slice(0, -1);
179
293
  }
180
- else {
294
+ else if (state.step === "api-key") {
181
295
  state.apiKey = state.apiKey.slice(0, -1);
182
296
  }
297
+ else if (selectedModelIndex(state) === (state.modelOptions?.length ?? 0)) {
298
+ state.customModel = (state.customModel ?? "").slice(0, -1);
299
+ }
183
300
  state.error = undefined;
184
301
  render();
185
302
  return;
@@ -188,9 +305,13 @@ async function readProviderForm(options) {
188
305
  if (state.step === "base-url") {
189
306
  state.baseUrl += character;
190
307
  }
191
- else {
308
+ else if (state.step === "api-key") {
192
309
  state.apiKey += character;
193
310
  }
311
+ else {
312
+ state.selectedModelIndex = state.modelOptions?.length ?? 0;
313
+ state.customModel = `${state.customModel ?? ""}${character}`;
314
+ }
194
315
  state.error = undefined;
195
316
  render();
196
317
  }
@@ -203,7 +324,19 @@ async function readProviderForm(options) {
203
324
  render();
204
325
  });
205
326
  }
206
- function renderChecking(frame, tty) {
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) {
207
340
  const frames = ["|", "/", "-", "\\"];
208
341
  const indicator = frames[frame % frames.length] ?? "|";
209
342
  return [
@@ -214,7 +347,7 @@ function renderChecking(frame, tty) {
214
347
  "Provider setup",
215
348
  "Validating the configured Responses API endpoint.",
216
349
  "",
217
- `${indicator} Checking provider connection with ${DEFAULT_OPENAI_MODEL}...`,
350
+ `${indicator} Checking provider connection with ${model}...`,
218
351
  ].join("\n");
219
352
  }
220
353
  export async function runProviderSetupTui(options) {
@@ -241,16 +374,17 @@ export async function runProviderSetupTui(options) {
241
374
  initialStep = "api-key";
242
375
  let frame = 0;
243
376
  const interval = setInterval(() => {
244
- stdout.write(renderChecking(frame, stdout.isTTY === true));
377
+ stdout.write(renderChecking(frame, stdout.isTTY === true, input.defaultModel));
245
378
  frame += 1;
246
379
  }, 120);
247
380
  try {
248
- stdout.write(renderChecking(frame, stdout.isTTY === true));
381
+ stdout.write(renderChecking(frame, stdout.isTTY === true, input.defaultModel));
249
382
  const result = await configureProjectOpenAiProvider({
250
383
  tuttiHome: options.tuttiHome,
251
384
  projectId: options.projectId,
252
385
  apiBaseUrl: input.baseUrl,
253
386
  apiKey: input.apiKey,
387
+ defaultModel: input.defaultModel,
254
388
  });
255
389
  clearInterval(interval);
256
390
  stdout.write(SHOW_CURSOR);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xfey/tutti",
3
- "version": "0.1.24",
3
+ "version": "0.1.26",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",