oh-pi 0.1.79 → 0.1.81

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/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import * as p from "@clack/prompts";
2
+ import chalk from "chalk";
2
3
  import { selectLanguage, getLocale } from "./i18n.js";
3
4
  import { t } from "./i18n.js";
4
5
  import { welcome } from "./tui/welcome.js";
@@ -11,6 +12,7 @@ import { selectExtensions } from "./tui/extension-select.js";
11
12
  import { selectAgents } from "./tui/agents-select.js";
12
13
  import { confirmApply } from "./tui/confirm-apply.js";
13
14
  import { detectEnv } from "./utils/detect.js";
15
+ import { EXTENSIONS } from "./registry.js";
14
16
  /**
15
17
  * 主入口函数。检测环境、选择语言、展示欢迎界面,根据用户选择的模式执行对应配置流程,最终确认并应用配置。
16
18
  */
@@ -65,19 +67,58 @@ async function presetFlow(env) {
65
67
  * @returns 生成的配置对象
66
68
  */
67
69
  async function customFlow(env) {
68
- const providerSetup = await setupProviders(env);
69
- const theme = await selectTheme();
70
- const keybindings = await selectKeybindings();
71
- const extensions = await selectExtensions();
72
- const agents = await selectAgents();
73
- // Advanced: auto-compaction is now automatic based on model context window
74
- const wantAdvanced = await p.confirm({
75
- message: t("advanced.configure"),
76
- initialValue: false,
77
- });
78
- if (p.isCancel(wantAdvanced)) {
79
- p.cancel(t("cancelled"));
80
- process.exit(0);
70
+ const defaultExtensions = EXTENSIONS.filter(e => e.default).map(e => e.name);
71
+ let providerSetup = null;
72
+ let theme = "dark";
73
+ let keybindings = "default";
74
+ let extensions = defaultExtensions;
75
+ let agents = "general-developer";
76
+ while (true) {
77
+ const tabBar = [
78
+ chalk.cyan(`[${t("custom.tabProviders")}]`),
79
+ chalk.cyan(`[${t("custom.tabAppearance")}]`),
80
+ chalk.cyan(`[${t("custom.tabFeatures")}]`),
81
+ chalk.cyan(`[${t("custom.tabAgents")}]`),
82
+ chalk.green(`[${t("custom.tabFinish")}]`),
83
+ ].join(chalk.gray(" | "));
84
+ const providerStatus = summarizeProviders(providerSetup);
85
+ p.note(`${tabBar}\n${providerStatus}`, t("custom.tabHeader"));
86
+ const tab = await p.select({
87
+ message: t("custom.tabPrompt"),
88
+ options: [
89
+ { value: "providers", label: t("custom.tabProviders"), hint: providerStatus },
90
+ { value: "appearance", label: t("custom.tabAppearance"), hint: `${theme} · ${keybindings}` },
91
+ { value: "features", label: t("custom.tabFeatures"), hint: t("custom.tabFeaturesHint", { count: extensions.length }) },
92
+ { value: "agents", label: t("custom.tabAgents"), hint: agents },
93
+ { value: "finish", label: t("custom.tabFinish"), hint: t("custom.tabFinishHint") },
94
+ ],
95
+ });
96
+ if (p.isCancel(tab)) {
97
+ p.cancel(t("cancelled"));
98
+ process.exit(0);
99
+ }
100
+ if (tab === "providers") {
101
+ providerSetup = await setupProviders(env);
102
+ continue;
103
+ }
104
+ if (tab === "appearance") {
105
+ theme = await selectTheme();
106
+ keybindings = await selectKeybindings();
107
+ continue;
108
+ }
109
+ if (tab === "features") {
110
+ extensions = await selectExtensions();
111
+ continue;
112
+ }
113
+ if (tab === "agents") {
114
+ agents = await selectAgents();
115
+ continue;
116
+ }
117
+ if (!providerSetup) {
118
+ p.log.warn(t("custom.needProviders"));
119
+ continue;
120
+ }
121
+ break;
81
122
  }
82
123
  return {
83
124
  ...providerSetup,
@@ -89,3 +130,17 @@ async function customFlow(env) {
89
130
  thinking: "medium",
90
131
  };
91
132
  }
133
+ function summarizeProviders(setup) {
134
+ if (!setup)
135
+ return t("custom.providersUnset");
136
+ if (setup.providerStrategy === "keep")
137
+ return t("confirm.providerStrategyKeep");
138
+ if (setup.providerStrategy === "add") {
139
+ return setup.providers.length > 0
140
+ ? t("custom.providersAdd", { list: setup.providers.map(p => p.name).join(", ") })
141
+ : t("confirm.providerStrategyAdd");
142
+ }
143
+ if (setup.providers.length === 0)
144
+ return t("confirm.providerStrategyReplace");
145
+ return t("custom.providersReplace", { list: setup.providers.map(p => p.name).join(", ") });
146
+ }
package/dist/locales.js CHANGED
@@ -18,6 +18,19 @@ export const messages = {
18
18
  "mode.presetHint": "Choose a pre-made configuration",
19
19
  "mode.custom": "🎛️ Custom",
20
20
  "mode.customHint": "Pick everything yourself",
21
+ "custom.tabHeader": "Custom Setup Tabs",
22
+ "custom.tabPrompt": "Select a tab to configure",
23
+ "custom.tabProviders": "Providers",
24
+ "custom.tabAppearance": "Appearance",
25
+ "custom.tabFeatures": "Features",
26
+ "custom.tabFeaturesHint": "{count} extensions enabled",
27
+ "custom.tabAgents": "Agent Profile",
28
+ "custom.tabFinish": "Finish",
29
+ "custom.tabFinishHint": "Review in next step and apply",
30
+ "custom.needProviders": "Please configure Providers first.",
31
+ "custom.providersUnset": "Providers: not configured",
32
+ "custom.providersAdd": "Add fallback providers: {list}",
33
+ "custom.providersReplace": "Replace providers with: {list}",
21
34
  // provider
22
35
  "provider.select": "Select API providers",
23
36
  "provider.selectPrimary": "Select your primary API provider",
@@ -28,16 +41,16 @@ export const messages = {
28
41
  "provider.customHint": "Ollama, vLLM, LiteLLM, any OpenAI-compatible",
29
42
  "provider.foundEnv": "Found {env} in environment. Use it?",
30
43
  "provider.customEndpoint": "Custom endpoint for {label}? (proxy, Azure, etc.)",
31
- "provider.baseUrl": "Base URL for {label}:",
44
+ "provider.baseUrl": "Base URL for {label} (usually without /v1):",
32
45
  "provider.useCustomUrl": "Use a custom endpoint for {label}? (proxy, self-hosted, etc.)",
33
- "provider.baseUrlPlaceholder": "https://your-proxy.example.com",
34
- "provider.baseUrlValidation": "Must be a valid URL",
46
+ "provider.baseUrlPlaceholder": "https://api.openai.com or https://your-proxy.example.com",
47
+ "provider.baseUrlValidation": "Must be a valid URL (tip: usually omit /v1)",
35
48
  "provider.configured": "{label} configured",
36
49
  "provider.name": "Provider name:",
37
50
  "provider.namePlaceholder": "ollama",
38
51
  "provider.nameRequired": "Name required",
39
- "provider.baseUrlCustom": "Base URL:",
40
- "provider.baseUrlCustomPlaceholder": "http://localhost:11434",
52
+ "provider.baseUrlCustom": "Base URL (usually without /v1):",
53
+ "provider.baseUrlCustomPlaceholder": "http://localhost:11434 (add /v1 only if your gateway requires it)",
41
54
  "provider.needsKey": "Requires API key?",
42
55
  "provider.apiKey": "API key for {label}:",
43
56
  "provider.apiKeyRequired": "API key cannot be empty",
@@ -168,6 +181,19 @@ export const messages = {
168
181
  "mode.presetHint": "选择预制配置",
169
182
  "mode.custom": "🎛️ 自定义",
170
183
  "mode.customHint": "逐项自选",
184
+ "custom.tabHeader": "自定义配置 Tabs",
185
+ "custom.tabPrompt": "选择一个页面进行配置",
186
+ "custom.tabProviders": "Providers",
187
+ "custom.tabAppearance": "外观",
188
+ "custom.tabFeatures": "功能",
189
+ "custom.tabFeaturesHint": "已启用 {count} 个扩展",
190
+ "custom.tabAgents": "Agent 模板",
191
+ "custom.tabFinish": "完成",
192
+ "custom.tabFinishHint": "下一步可预览并应用",
193
+ "custom.needProviders": "请先配置 Providers。",
194
+ "custom.providersUnset": "Providers:未配置",
195
+ "custom.providersAdd": "新增备用 Providers:{list}",
196
+ "custom.providersReplace": "替换为 Providers:{list}",
171
197
  "provider.select": "选择 API 提供商",
172
198
  "provider.selectPrimary": "选择主 Provider",
173
199
  "provider.selectAdditional": "选择新增 Provider",
@@ -177,16 +203,16 @@ export const messages = {
177
203
  "provider.customHint": "Ollama、vLLM、LiteLLM 等 OpenAI 兼容接口",
178
204
  "provider.foundEnv": "在环境变量中找到 {env},是否使用?",
179
205
  "provider.customEndpoint": "为 {label} 设置自定义端点?(代理、Azure 等)",
180
- "provider.baseUrl": "{label} 的 Base URL",
206
+ "provider.baseUrl": "{label} 的 Base URL(通常不要带 /v1):",
181
207
  "provider.useCustomUrl": "是否使用自定义端点?(代理、自托管等)",
182
- "provider.baseUrlPlaceholder": "https://your-proxy.example.com",
183
- "provider.baseUrlValidation": "必须是有效的 URL",
208
+ "provider.baseUrlPlaceholder": "https://api.openai.com 或 https://your-proxy.example.com",
209
+ "provider.baseUrlValidation": "必须是有效 URL(通常不需要 /v1)",
184
210
  "provider.configured": "{label} 配置完成",
185
211
  "provider.name": "提供商名称:",
186
212
  "provider.namePlaceholder": "ollama",
187
213
  "provider.nameRequired": "名称不能为空",
188
- "provider.baseUrlCustom": "Base URL",
189
- "provider.baseUrlCustomPlaceholder": "http://localhost:11434",
214
+ "provider.baseUrlCustom": "Base URL(通常不要带 /v1):",
215
+ "provider.baseUrlCustomPlaceholder": "http://localhost:11434(仅当网关要求时再加 /v1)",
190
216
  "provider.needsKey": "需要 API 密钥?",
191
217
  "provider.apiKey": "{label} 的 API 密钥:",
192
218
  "provider.apiKeyRequired": "API 密钥不能为空",
@@ -309,6 +335,19 @@ export const messages = {
309
335
  "mode.presetHint": "Choisir une configuration prédéfinie",
310
336
  "mode.custom": "🎛️ Personnalisé",
311
337
  "mode.customHint": "Tout choisir soi-même",
338
+ "custom.tabHeader": "Onglets de configuration personnalisée",
339
+ "custom.tabPrompt": "Choisissez un onglet à configurer",
340
+ "custom.tabProviders": "Fournisseurs",
341
+ "custom.tabAppearance": "Apparence",
342
+ "custom.tabFeatures": "Fonctionnalités",
343
+ "custom.tabFeaturesHint": "{count} extensions activées",
344
+ "custom.tabAgents": "Profil Agent",
345
+ "custom.tabFinish": "Terminer",
346
+ "custom.tabFinishHint": "Prévisualiser puis appliquer à l'étape suivante",
347
+ "custom.needProviders": "Veuillez d'abord configurer les fournisseurs.",
348
+ "custom.providersUnset": "Fournisseurs : non configurés",
349
+ "custom.providersAdd": "Ajouter des fournisseurs de secours : {list}",
350
+ "custom.providersReplace": "Remplacer par : {list}",
312
351
  "provider.select": "Sélectionner les fournisseurs API",
313
352
  "provider.selectPrimary": "Sélectionner le fournisseur API principal",
314
353
  "provider.selectAdditional": "Sélectionner un fournisseur supplémentaire",
@@ -318,16 +357,16 @@ export const messages = {
318
357
  "provider.customHint": "Ollama, vLLM, LiteLLM, tout compatible OpenAI",
319
358
  "provider.foundEnv": "{env} trouvé dans l'environnement. L'utiliser ?",
320
359
  "provider.customEndpoint": "Point d'accès personnalisé pour {label} ? (proxy, Azure, etc.)",
321
- "provider.baseUrl": "URL de base pour {label} :",
360
+ "provider.baseUrl": "URL de base pour {label} (généralement sans /v1) :",
322
361
  "provider.useCustomUrl": "Utiliser un endpoint personnalisé pour {label} ? (proxy, auto-hébergé, etc.)",
323
- "provider.baseUrlPlaceholder": "https://your-proxy.example.com",
324
- "provider.baseUrlValidation": "Doit être une URL valide",
362
+ "provider.baseUrlPlaceholder": "https://api.openai.com ou https://your-proxy.example.com",
363
+ "provider.baseUrlValidation": "Doit être une URL valide (astuce : sans /v1 dans la plupart des cas)",
325
364
  "provider.configured": "{label} configuré",
326
365
  "provider.name": "Nom du fournisseur :",
327
366
  "provider.namePlaceholder": "ollama",
328
367
  "provider.nameRequired": "Nom requis",
329
- "provider.baseUrlCustom": "URL de base :",
330
- "provider.baseUrlCustomPlaceholder": "http://localhost:11434",
368
+ "provider.baseUrlCustom": "URL de base (généralement sans /v1) :",
369
+ "provider.baseUrlCustomPlaceholder": "http://localhost:11434 (ajoutez /v1 seulement si votre passerelle l'exige)",
331
370
  "provider.needsKey": "Nécessite une clé API ?",
332
371
  "provider.apiKey": "Clé API pour {label} :",
333
372
  "provider.apiKeyRequired": "La clé API ne peut pas être vide",
@@ -1,5 +1,10 @@
1
1
  import type { ProviderConfig, ProviderSetupStrategy } from "../types.js";
2
2
  import type { EnvInfo } from "../utils/detect.js";
3
+ /**
4
+ * Normalize user-entered base URL for model discovery probes.
5
+ * Discovery always calls `${base}/v1/models`, so strip trailing `/v1` to avoid `/v1/v1/models`.
6
+ */
7
+ export declare function normalizeDiscoveryBaseUrl(baseUrl: string): string;
3
8
  /** Block internal/private IPs to prevent SSRF */
4
9
  export declare function isUnsafeUrl(urlStr: string): boolean;
5
10
  type OpenAIApiMode = "auto" | "openai-responses" | "openai-completions";
@@ -8,6 +13,7 @@ export interface ProviderSetupResult {
8
13
  providerStrategy: ProviderSetupStrategy;
9
14
  }
10
15
  export declare function resolveOpenAIApiMode(mode: OpenAIApiMode, modelId: string): "openai-responses" | "openai-completions";
16
+ export declare function isOpenAICompatibleApi(api?: string): boolean;
11
17
  /**
12
18
  * Interactively configure API providers with an explicit strategy.
13
19
  * Default flow: one primary provider. Advanced flow: optional fallback providers.
@@ -12,6 +12,14 @@ const PROVIDER_API_URLS = {
12
12
  xai: "https://api.x.ai",
13
13
  mistral: "https://api.mistral.ai",
14
14
  };
15
+ /**
16
+ * Normalize user-entered base URL for model discovery probes.
17
+ * Discovery always calls `${base}/v1/models`, so strip trailing `/v1` to avoid `/v1/v1/models`.
18
+ */
19
+ export function normalizeDiscoveryBaseUrl(baseUrl) {
20
+ const trimmed = baseUrl.trim().replace(/\/+$/, "");
21
+ return trimmed.replace(/\/v1$/i, "");
22
+ }
15
23
  /** Block internal/private IPs to prevent SSRF */
16
24
  export function isUnsafeUrl(urlStr) {
17
25
  try {
@@ -50,6 +58,9 @@ export function resolveOpenAIApiMode(mode, modelId) {
50
58
  return "openai-responses";
51
59
  return "openai-completions";
52
60
  }
61
+ export function isOpenAICompatibleApi(api) {
62
+ return !api || api === "openai-completions" || api === "openai-responses";
63
+ }
53
64
  /**
54
65
  * 动态获取模型列表,依次尝试 Anthropic、Google、OpenAI 兼容 API 风格。
55
66
  * @param provider - 提供商名称
@@ -58,7 +69,7 @@ export function resolveOpenAIApiMode(mode, modelId) {
58
69
  * @returns 发现的模型列表及检测到的 API 类型
59
70
  */
60
71
  async function fetchModels(provider, baseUrl, apiKey) {
61
- const base = baseUrl.replace(/\/+$/, "");
72
+ const base = normalizeDiscoveryBaseUrl(baseUrl);
62
73
  const resolvedKey = process.env[apiKey] ?? apiKey;
63
74
  // Try Anthropic-style first (for known anthropic or any provider)
64
75
  try {
@@ -274,7 +285,7 @@ async function setupProviderChoice(choice) {
274
285
  if (useCustomUrl) {
275
286
  const url = await p.text({
276
287
  message: t("provider.baseUrl", { label: info.label }),
277
- placeholder: "https://proxy.example.com",
288
+ placeholder: t("provider.baseUrlPlaceholder"),
278
289
  validate: (v) => (!v || !v.startsWith("http")) ? t("provider.baseUrlValidation") : isUnsafeUrl(v) ? "URL must use HTTPS for remote hosts (private IPs blocked)" : undefined,
279
290
  });
280
291
  if (p.isCancel(url)) {
@@ -352,8 +363,11 @@ async function setupCustomProvider() {
352
363
  apiKey = await promptKey(name);
353
364
  }
354
365
  const { defaultModel, discoveredModels, api } = await selectModelWithMeta(name, name, [], baseUrl, apiKey);
366
+ const finalApi = isOpenAICompatibleApi(api)
367
+ ? await selectOpenAIApiMode(name, defaultModel)
368
+ : api;
355
369
  p.log.success(t("provider.customConfigured", { name, url: baseUrl }));
356
- return { name, apiKey, defaultModel, baseUrl, api, discoveredModels };
370
+ return { name, apiKey, defaultModel, baseUrl, api: finalApi, discoveredModels };
357
371
  }
358
372
  /**
359
373
  * Select a default model by dynamically fetching available models, falling back to a static list or manual input.
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect } from "vitest";
2
- import { isUnsafeUrl, resolveOpenAIApiMode } from "./provider-setup.js";
2
+ import { isOpenAICompatibleApi, isUnsafeUrl, normalizeDiscoveryBaseUrl, resolveOpenAIApiMode } from "./provider-setup.js";
3
3
  describe("isUnsafeUrl", () => {
4
4
  it("https remote is safe", () => {
5
5
  expect(isUnsafeUrl("https://api.example.com")).toBe(false);
@@ -52,3 +52,31 @@ describe("resolveOpenAIApiMode", () => {
52
52
  expect(resolveOpenAIApiMode("auto", "gpt-4o")).toBe("openai-completions");
53
53
  });
54
54
  });
55
+ describe("normalizeDiscoveryBaseUrl", () => {
56
+ it("keeps regular host URL", () => {
57
+ expect(normalizeDiscoveryBaseUrl("https://api.openai.com")).toBe("https://api.openai.com");
58
+ });
59
+ it("strips trailing slash", () => {
60
+ expect(normalizeDiscoveryBaseUrl("https://api.openai.com/")).toBe("https://api.openai.com");
61
+ });
62
+ it("strips trailing /v1 to avoid /v1/v1 probe", () => {
63
+ expect(normalizeDiscoveryBaseUrl("https://proxy.example.com/v1")).toBe("https://proxy.example.com");
64
+ });
65
+ it("strips trailing /v1/ to avoid /v1/v1 probe", () => {
66
+ expect(normalizeDiscoveryBaseUrl("http://localhost:11434/v1/")).toBe("http://localhost:11434");
67
+ });
68
+ });
69
+ describe("isOpenAICompatibleApi", () => {
70
+ it("treats undefined as openai-compatible", () => {
71
+ expect(isOpenAICompatibleApi(undefined)).toBe(true);
72
+ });
73
+ it("treats openai-completions as openai-compatible", () => {
74
+ expect(isOpenAICompatibleApi("openai-completions")).toBe(true);
75
+ });
76
+ it("treats openai-responses as openai-compatible", () => {
77
+ expect(isOpenAICompatibleApi("openai-responses")).toBe(true);
78
+ });
79
+ it("treats anthropic api as non-openai-compatible", () => {
80
+ expect(isOpenAICompatibleApi("anthropic-messages")).toBe(false);
81
+ });
82
+ });
@@ -88,16 +88,25 @@ export function writeModelConfig(agentDir, config) {
88
88
  return;
89
89
  const strategy = config.providerStrategy ?? "replace";
90
90
  const modelsPath = join(agentDir, "models.json");
91
- const customProviders = config.providers.filter(p => p.baseUrl);
92
- if (customProviders.length === 0)
91
+ // Persist custom endpoints and API mode overrides (e.g. built-in OpenAI responses/completions choice).
92
+ const modelProviders = config.providers.filter(p => p.baseUrl || (!!p.api && !!PROVIDERS[p.name]));
93
+ if (modelProviders.length === 0)
93
94
  return;
94
95
  const providers = strategy === "add"
95
96
  ? (readJson(modelsPath)?.providers ?? {})
96
97
  : {};
97
- for (const cp of customProviders) {
98
+ for (const cp of modelProviders) {
98
99
  const isBuiltin = !!PROVIDERS[cp.name];
100
+ if (!cp.baseUrl && isBuiltin && cp.api) {
101
+ providers[cp.name] = { api: cp.api };
102
+ continue;
103
+ }
104
+ if (!cp.baseUrl)
105
+ continue;
99
106
  if (isBuiltin && !cp.discoveredModels?.length) {
100
107
  const entry = { baseUrl: cp.baseUrl };
108
+ if (cp.api)
109
+ entry.api = cp.api;
101
110
  if (cp.apiKey !== "none")
102
111
  entry.apiKey = cp.apiKey;
103
112
  providers[cp.name] = entry;
@@ -107,6 +107,41 @@ describe("provider keep strategy", () => {
107
107
  expect(text).toContain("\"custom-openai\"");
108
108
  expect(text).toContain("\"openai-responses\"");
109
109
  });
110
+ it("writeModelConfig persists API mode for builtin OpenAI without custom baseUrl", () => {
111
+ const dir = makeTempDir();
112
+ writeModelConfig(dir, makeConfig({
113
+ providerStrategy: "replace",
114
+ providers: [{
115
+ name: "openai",
116
+ apiKey: "OPENAI_API_KEY",
117
+ defaultModel: "gpt-5",
118
+ api: "openai-responses",
119
+ }],
120
+ }));
121
+ const modelsPath = join(dir, "models.json");
122
+ expect(existsSync(modelsPath)).toBe(true);
123
+ const models = JSON.parse(readFileSync(modelsPath, "utf8"));
124
+ expect(models.providers.openai.api).toBe("openai-responses");
125
+ expect(models.providers.openai.baseUrl).toBeUndefined();
126
+ });
127
+ it("writeModelConfig keeps API mode when overriding builtin baseUrl without discovered models", () => {
128
+ const dir = makeTempDir();
129
+ writeModelConfig(dir, makeConfig({
130
+ providerStrategy: "replace",
131
+ providers: [{
132
+ name: "openai",
133
+ apiKey: "OPENAI_API_KEY",
134
+ baseUrl: "https://api.openai.com/v1",
135
+ defaultModel: "gpt-4o",
136
+ api: "openai-responses",
137
+ }],
138
+ }));
139
+ const modelsPath = join(dir, "models.json");
140
+ expect(existsSync(modelsPath)).toBe(true);
141
+ const models = JSON.parse(readFileSync(modelsPath, "utf8"));
142
+ expect(models.providers.openai.baseUrl).toBe("https://api.openai.com/v1");
143
+ expect(models.providers.openai.api).toBe("openai-responses");
144
+ });
110
145
  it("writeProviderEnv merges auth/settings when strategy is add", () => {
111
146
  const dir = makeTempDir();
112
147
  const settingsPath = join(dir, "settings.json");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oh-pi",
3
- "version": "0.1.79",
3
+ "version": "0.1.81",
4
4
  "description": "One-click setup for pi-coding-agent. Like oh-my-zsh for pi.",
5
5
  "type": "module",
6
6
  "bin": {