oh-pi 0.1.78 → 0.1.80

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
@@ -38,9 +38,9 @@ export async function run() {
38
38
  * @returns 生成的配置对象
39
39
  */
40
40
  async function quickFlow(env) {
41
- const providers = await setupProviders(env);
41
+ const providerSetup = await setupProviders(env);
42
42
  return {
43
- providers,
43
+ ...providerSetup,
44
44
  theme: "dark",
45
45
  keybindings: "default",
46
46
  extensions: ["safe-guard", "git-guard", "auto-session-name", "custom-footer", "compact-header", "auto-update"],
@@ -56,8 +56,8 @@ async function quickFlow(env) {
56
56
  */
57
57
  async function presetFlow(env) {
58
58
  const preset = await selectPreset();
59
- const providers = await setupProviders(env);
60
- return { ...preset, providers };
59
+ const providerSetup = await setupProviders(env);
60
+ return { ...preset, ...providerSetup };
61
61
  }
62
62
  /**
63
63
  * 自定义配置流程。用户逐项选择主题、快捷键、扩展、代理等,并可配置高级选项(如自动压缩阈值)。
@@ -65,7 +65,7 @@ async function presetFlow(env) {
65
65
  * @returns 生成的配置对象
66
66
  */
67
67
  async function customFlow(env) {
68
- const providers = await setupProviders(env);
68
+ const providerSetup = await setupProviders(env);
69
69
  const theme = await selectTheme();
70
70
  const keybindings = await selectKeybindings();
71
71
  const extensions = await selectExtensions();
@@ -80,7 +80,7 @@ async function customFlow(env) {
80
80
  process.exit(0);
81
81
  }
82
82
  return {
83
- providers,
83
+ ...providerSetup,
84
84
  theme,
85
85
  keybindings,
86
86
  extensions,
package/dist/locales.js CHANGED
@@ -20,6 +20,10 @@ export const messages = {
20
20
  "mode.customHint": "Pick everything yourself",
21
21
  // provider
22
22
  "provider.select": "Select API providers",
23
+ "provider.selectPrimary": "Select your primary API provider",
24
+ "provider.selectAdditional": "Select an additional provider",
25
+ "provider.addFallback": "Add a fallback provider? (advanced)",
26
+ "provider.selectFallback": "Select a fallback provider",
23
27
  "provider.custom": "🔧 Custom endpoint",
24
28
  "provider.customHint": "Ollama, vLLM, LiteLLM, any OpenAI-compatible",
25
29
  "provider.foundEnv": "Found {env} in environment. Use it?",
@@ -46,6 +50,13 @@ export const messages = {
46
50
  "provider.modelNamePlaceholder": "llama3.1:8b",
47
51
  "provider.modelNameRequired": "Model name required",
48
52
  "provider.customConfigured": "{name} configured ({url})",
53
+ "provider.apiMode": "OpenAI API mode for {label}:",
54
+ "provider.apiModeAuto": "Auto (Recommended)",
55
+ "provider.apiModeAutoHint": "Use Responses for reasoning-first models (selected: {model})",
56
+ "provider.apiModeResponses": "Responses API",
57
+ "provider.apiModeResponsesHint": "Modern OpenAI API surface",
58
+ "provider.apiModeCompletions": "Chat Completions API",
59
+ "provider.apiModeCompletionsHint": "Legacy-compatible endpoint",
49
60
  "provider.configureCaps": "Configure model capabilities? (context window, multimodal, reasoning)",
50
61
  "provider.contextWindow": "Context window size (tokens):",
51
62
  "provider.contextWindowValidation": "Must be a number ≥ 1024",
@@ -54,10 +65,12 @@ export const messages = {
54
65
  "provider.multimodal": "Supports image input (multimodal)?",
55
66
  "provider.reasoning": "Supports extended thinking (reasoning)?",
56
67
  "provider.detected": "Existing providers detected: {list}",
57
- "provider.detectedSkip": "⏭ Skip keep existing",
58
- "provider.detectedSkipHint": "Don't change provider config",
59
- "provider.detectedAdd": " Add new providers",
60
- "provider.detectedAddHint": "Configure additional providers",
68
+ "provider.detectedKeep": "⏭ Keep existing provider config",
69
+ "provider.detectedKeepHint": "Do not modify auth/settings/models provider entries",
70
+ "provider.detectedReplace": "♻️ Replace provider config",
71
+ "provider.detectedReplaceHint": "Reconfigure primary provider and optional fallbacks",
72
+ "provider.detectedAdd": "➕ Keep existing and add fallback providers",
73
+ "provider.detectedAddHint": "Keep current primary provider and append additional providers",
61
74
  // preset
62
75
  "preset.select": "Choose a preset:",
63
76
  "preset.full": "⚫ Full Power",
@@ -97,7 +110,12 @@ export const messages = {
97
110
  // confirm
98
111
  "confirm.title": "Configuration",
99
112
  "confirm.providers": "Providers:",
113
+ "confirm.providerStrategy": "Provider strategy:",
114
+ "confirm.providerStrategyKeep": "keep existing",
115
+ "confirm.providerStrategyAdd": "keep existing and add fallback providers",
116
+ "confirm.providerStrategyReplace": "replace with new setup",
100
117
  "confirm.model": "Model:",
118
+ "confirm.fallbackProviders": "Fallback providers:",
101
119
  "confirm.theme": "Theme:",
102
120
  "confirm.keybindings": "Keybindings:",
103
121
  "confirm.thinking": "Thinking:",
@@ -151,6 +169,10 @@ export const messages = {
151
169
  "mode.custom": "🎛️ 自定义",
152
170
  "mode.customHint": "逐项自选",
153
171
  "provider.select": "选择 API 提供商",
172
+ "provider.selectPrimary": "选择主 Provider",
173
+ "provider.selectAdditional": "选择新增 Provider",
174
+ "provider.addFallback": "是否添加备用 Provider?(高级)",
175
+ "provider.selectFallback": "选择备用 Provider",
154
176
  "provider.custom": "🔧 自定义端点",
155
177
  "provider.customHint": "Ollama、vLLM、LiteLLM 等 OpenAI 兼容接口",
156
178
  "provider.foundEnv": "在环境变量中找到 {env},是否使用?",
@@ -177,6 +199,13 @@ export const messages = {
177
199
  "provider.modelNamePlaceholder": "llama3.1:8b",
178
200
  "provider.modelNameRequired": "模型名称不能为空",
179
201
  "provider.customConfigured": "{name} 配置完成 ({url})",
202
+ "provider.apiMode": "{label} 的 OpenAI API 模式:",
203
+ "provider.apiModeAuto": "自动(推荐)",
204
+ "provider.apiModeAutoHint": "推理模型优先用 Responses(当前模型:{model})",
205
+ "provider.apiModeResponses": "Responses API",
206
+ "provider.apiModeResponsesHint": "OpenAI 新版 API",
207
+ "provider.apiModeCompletions": "Chat Completions API",
208
+ "provider.apiModeCompletionsHint": "兼容旧接口",
180
209
  "provider.configureCaps": "配置模型能力?(上下文窗口、多模态、推理)",
181
210
  "provider.contextWindow": "上下文窗口大小(tokens):",
182
211
  "provider.contextWindowValidation": "必须是 ≥ 1024 的数字",
@@ -185,10 +214,12 @@ export const messages = {
185
214
  "provider.multimodal": "支持图片输入(多模态)?",
186
215
  "provider.reasoning": "支持扩展思考(推理)?",
187
216
  "provider.detected": "检测到已有 Provider: {list}",
188
- "provider.detectedSkip": "⏭ 跳过 保留现有配置",
189
- "provider.detectedSkipHint": "不修改 Provider 配置",
190
- "provider.detectedAdd": " 添加新 Provider",
191
- "provider.detectedAddHint": "配置额外的 Provider",
217
+ "provider.detectedKeep": "⏭ 保留现有 Provider 配置",
218
+ "provider.detectedKeepHint": "不修改 auth/settings/models 中的 Provider ",
219
+ "provider.detectedReplace": "♻️ 重新配置 Provider",
220
+ "provider.detectedReplaceHint": "重设主 Provider,并可选配置备用 Provider",
221
+ "provider.detectedAdd": "➕ 保留现有并新增备用 Provider",
222
+ "provider.detectedAddHint": "保持当前主 Provider,仅追加新的 Provider",
192
223
  "preset.select": "选择预设方案:",
193
224
  "preset.full": "⚫ 全功能",
194
225
  "preset.fullHint": "所有功能,包括蚁群系统",
@@ -221,7 +252,12 @@ export const messages = {
221
252
  "advanced.compactValidation": "必须是 10 到 100 之间的数字",
222
253
  "confirm.title": "配置摘要",
223
254
  "confirm.providers": "提供商:",
255
+ "confirm.providerStrategy": "Provider 策略:",
256
+ "confirm.providerStrategyKeep": "保留现有",
257
+ "confirm.providerStrategyAdd": "保留现有并新增备用提供商",
258
+ "confirm.providerStrategyReplace": "替换为新配置",
224
259
  "confirm.model": "模型:",
260
+ "confirm.fallbackProviders": "备用提供商:",
225
261
  "confirm.theme": "主题:",
226
262
  "confirm.keybindings": "快捷键:",
227
263
  "confirm.thinking": "思考:",
@@ -274,6 +310,10 @@ export const messages = {
274
310
  "mode.custom": "🎛️ Personnalisé",
275
311
  "mode.customHint": "Tout choisir soi-même",
276
312
  "provider.select": "Sélectionner les fournisseurs API",
313
+ "provider.selectPrimary": "Sélectionner le fournisseur API principal",
314
+ "provider.selectAdditional": "Sélectionner un fournisseur supplémentaire",
315
+ "provider.addFallback": "Ajouter un fournisseur de secours ? (avancé)",
316
+ "provider.selectFallback": "Sélectionner un fournisseur de secours",
277
317
  "provider.custom": "🔧 Point d'accès personnalisé",
278
318
  "provider.customHint": "Ollama, vLLM, LiteLLM, tout compatible OpenAI",
279
319
  "provider.foundEnv": "{env} trouvé dans l'environnement. L'utiliser ?",
@@ -300,6 +340,13 @@ export const messages = {
300
340
  "provider.modelNamePlaceholder": "llama3.1:8b",
301
341
  "provider.modelNameRequired": "Nom du modèle requis",
302
342
  "provider.customConfigured": "{name} configuré ({url})",
343
+ "provider.apiMode": "Mode API OpenAI pour {label} :",
344
+ "provider.apiModeAuto": "Auto (Recommandé)",
345
+ "provider.apiModeAutoHint": "Utilise Responses pour les modèles orientés raisonnement (sélectionné : {model})",
346
+ "provider.apiModeResponses": "API Responses",
347
+ "provider.apiModeResponsesHint": "Surface API OpenAI moderne",
348
+ "provider.apiModeCompletions": "API Chat Completions",
349
+ "provider.apiModeCompletionsHint": "Endpoint compatible historique",
303
350
  "provider.configureCaps": "Configurer les capacités du modèle ? (fenêtre de contexte, multimodal, raisonnement)",
304
351
  "provider.contextWindow": "Taille de la fenêtre de contexte (tokens) :",
305
352
  "provider.contextWindowValidation": "Doit être un nombre ≥ 1024",
@@ -308,10 +355,12 @@ export const messages = {
308
355
  "provider.multimodal": "Prend en charge l'entrée d'images (multimodal) ?",
309
356
  "provider.reasoning": "Prend en charge la réflexion étendue (raisonnement) ?",
310
357
  "provider.detected": "Fournisseurs existants détectés : {list}",
311
- "provider.detectedSkip": "⏭ Passer garder l'existant",
312
- "provider.detectedSkipHint": "Ne pas modifier la config des fournisseurs",
313
- "provider.detectedAdd": " Ajouter de nouveaux fournisseurs",
314
- "provider.detectedAddHint": "Configurer des fournisseurs supplémentaires",
358
+ "provider.detectedKeep": "⏭ Garder la config fournisseur existante",
359
+ "provider.detectedKeepHint": "Ne modifie pas les entrées fournisseur de auth/settings/models",
360
+ "provider.detectedReplace": "♻️ Remplacer la config fournisseur",
361
+ "provider.detectedReplaceHint": "Reconfigure le fournisseur principal et des secours optionnels",
362
+ "provider.detectedAdd": "➕ Garder l'existant et ajouter des secours",
363
+ "provider.detectedAddHint": "Conserve le fournisseur principal actuel et ajoute des fournisseurs supplémentaires",
315
364
  "preset.select": "Choisir un préréglage :",
316
365
  "preset.full": "⚫ Complet",
317
366
  "preset.fullHint": "Toutes les extensions et compétences",
@@ -344,7 +393,12 @@ export const messages = {
344
393
  "advanced.compactValidation": "Doit être un nombre entre 10 et 100",
345
394
  "confirm.title": "Configuration",
346
395
  "confirm.providers": "Fournisseurs :",
396
+ "confirm.providerStrategy": "Stratégie fournisseur :",
397
+ "confirm.providerStrategyKeep": "garder l'existant",
398
+ "confirm.providerStrategyAdd": "garder l'existant et ajouter des fournisseurs de secours",
399
+ "confirm.providerStrategyReplace": "remplacer par une nouvelle configuration",
347
400
  "confirm.model": "Modèle :",
401
+ "confirm.fallbackProviders": "Fournisseurs de secours :",
348
402
  "confirm.theme": "Thème :",
349
403
  "confirm.keybindings": "Raccourcis :",
350
404
  "confirm.thinking": "Réflexion :",
@@ -17,10 +17,32 @@ export function countExisting(env, dir) {
17
17
  * @param env - 当前环境信息
18
18
  */
19
19
  export async function confirmApply(config, env) {
20
+ const keepProviders = config.providerStrategy === "keep";
21
+ const addProviders = config.providerStrategy === "add";
22
+ const providerNames = keepProviders || addProviders
23
+ ? t("confirm.skipped")
24
+ : (config.providers.length > 0 ? config.providers.map(p => p.name).join(", ") : t("confirm.none"));
25
+ const primaryModel = keepProviders
26
+ ? t("confirm.skipped")
27
+ : addProviders
28
+ ? t("confirm.skipped")
29
+ : (config.providers[0]?.defaultModel || t("confirm.none"));
30
+ const fallbackProviders = keepProviders
31
+ ? t("confirm.skipped")
32
+ : addProviders
33
+ ? (config.providers.length > 0 ? config.providers.map(p => p.name).join(", ") : t("confirm.none"))
34
+ : (config.providers.length > 1 ? config.providers.slice(1).map(p => p.name).join(", ") : t("confirm.none"));
35
+ const providerStrategy = keepProviders
36
+ ? t("confirm.providerStrategyKeep")
37
+ : addProviders
38
+ ? t("confirm.providerStrategyAdd")
39
+ : t("confirm.providerStrategyReplace");
20
40
  // ═══ Summary ═══
21
41
  const summary = [
22
- `${t("confirm.providers")} ${chalk.cyan(config.providers.length > 0 ? config.providers.map(p => p.name).join(", ") : t("confirm.skipped"))}`,
23
- `${t("confirm.model")} ${chalk.cyan(config.providers[0]?.defaultModel || t("confirm.skipped"))}`,
42
+ `${t("confirm.providerStrategy")} ${chalk.cyan(providerStrategy)}`,
43
+ `${t("confirm.providers")} ${chalk.cyan(providerNames)}`,
44
+ `${t("confirm.model")} ${chalk.cyan(primaryModel)}`,
45
+ `${t("confirm.fallbackProviders")} ${chalk.cyan(fallbackProviders)}`,
24
46
  `${t("confirm.theme")} ${chalk.cyan(config.theme)}`,
25
47
  `${t("confirm.keybindings")}${chalk.cyan(config.keybindings)}`,
26
48
  `${t("confirm.thinking")} ${chalk.cyan(config.thinking)}`,
@@ -1,10 +1,18 @@
1
- import type { ProviderConfig } from "../types.js";
1
+ import type { ProviderConfig, ProviderSetupStrategy } from "../types.js";
2
2
  import type { EnvInfo } from "../utils/detect.js";
3
3
  /** Block internal/private IPs to prevent SSRF */
4
4
  export declare function isUnsafeUrl(urlStr: string): boolean;
5
+ type OpenAIApiMode = "auto" | "openai-responses" | "openai-completions";
6
+ export interface ProviderSetupResult {
7
+ providers: ProviderConfig[];
8
+ providerStrategy: ProviderSetupStrategy;
9
+ }
10
+ export declare function resolveOpenAIApiMode(mode: OpenAIApiMode, modelId: string): "openai-responses" | "openai-completions";
5
11
  /**
6
- * Interactively configure API providers, detecting existing keys, allowing multi-select, and supporting custom endpoints.
12
+ * Interactively configure API providers with an explicit strategy.
13
+ * Default flow: one primary provider. Advanced flow: optional fallback providers.
7
14
  * @param env - Current environment info with detected providers
8
- * @returns Configured provider list
15
+ * @returns Provider setup result with strategy and selected providers
9
16
  */
10
- export declare function setupProviders(env?: EnvInfo): Promise<ProviderConfig[]>;
17
+ export declare function setupProviders(env?: EnvInfo): Promise<ProviderSetupResult>;
18
+ export {};
@@ -42,6 +42,14 @@ export function isUnsafeUrl(urlStr) {
42
42
  return true;
43
43
  }
44
44
  }
45
+ export function resolveOpenAIApiMode(mode, modelId) {
46
+ if (mode !== "auto")
47
+ return mode;
48
+ const model = modelId.toLowerCase();
49
+ if (/^(o\d|gpt-5|gpt-4\.1|gpt-4\.5)/.test(model))
50
+ return "openai-responses";
51
+ return "openai-completions";
52
+ }
45
53
  /**
46
54
  * 动态获取模型列表,依次尝试 Anthropic、Google、OpenAI 兼容 API 风格。
47
55
  * @param provider - 提供商名称
@@ -128,19 +136,22 @@ async function fetchModels(provider, baseUrl, apiKey) {
128
136
  return { models: [] };
129
137
  }
130
138
  /**
131
- * Interactively configure API providers, detecting existing keys, allowing multi-select, and supporting custom endpoints.
139
+ * Interactively configure API providers with an explicit strategy.
140
+ * Default flow: one primary provider. Advanced flow: optional fallback providers.
132
141
  * @param env - Current environment info with detected providers
133
- * @returns Configured provider list
142
+ * @returns Provider setup result with strategy and selected providers
134
143
  */
135
144
  export async function setupProviders(env) {
136
145
  const entries = Object.entries(PROVIDERS);
137
- // Detect existing providers — offer skip or add new
146
+ let providerStrategy = "replace";
147
+ // Detect existing providers — offer keep / replace / add
138
148
  const detected = env?.existingProviders ?? [];
139
149
  if (detected.length > 0) {
140
150
  const action = await p.select({
141
151
  message: t("provider.detected", { list: detected.join(", ") }),
142
152
  options: [
143
- { value: "skip", label: t("provider.detectedSkip"), hint: t("provider.detectedSkipHint") },
153
+ { value: "keep", label: t("provider.detectedKeep"), hint: t("provider.detectedKeepHint") },
154
+ { value: "replace", label: t("provider.detectedReplace"), hint: t("provider.detectedReplaceHint") },
144
155
  { value: "add", label: t("provider.detectedAdd"), hint: t("provider.detectedAddHint") },
145
156
  ],
146
157
  });
@@ -148,73 +159,165 @@ export async function setupProviders(env) {
148
159
  p.cancel(t("cancelled"));
149
160
  process.exit(0);
150
161
  }
151
- if (action === "skip")
152
- return [];
162
+ if (action === "keep") {
163
+ return { providers: [], providerStrategy: "keep" };
164
+ }
165
+ providerStrategy = action;
166
+ }
167
+ const selected = new Set(providerStrategy === "add" ? detected : []);
168
+ const configs = [];
169
+ if (providerStrategy === "add") {
170
+ let pickedAny = false;
171
+ while (true) {
172
+ const options = [
173
+ ...entries
174
+ .filter(([key]) => !selected.has(key))
175
+ .map(([key, info]) => ({ value: key, label: info.label, hint: info.env })),
176
+ ...(!selected.has("_custom") ? [{ value: "_custom", label: t("provider.custom"), hint: t("provider.customHint") }] : []),
177
+ ];
178
+ if (options.length === 0)
179
+ break;
180
+ if (pickedAny) {
181
+ const addMore = await p.confirm({
182
+ message: t("provider.addFallback"),
183
+ initialValue: false,
184
+ });
185
+ if (p.isCancel(addMore)) {
186
+ p.cancel(t("cancelled"));
187
+ process.exit(0);
188
+ }
189
+ if (!addMore)
190
+ break;
191
+ }
192
+ const next = await p.select({
193
+ message: pickedAny ? t("provider.selectFallback") : t("provider.selectAdditional"),
194
+ options,
195
+ });
196
+ if (p.isCancel(next)) {
197
+ p.cancel(t("cancelled"));
198
+ process.exit(0);
199
+ }
200
+ selected.add(next);
201
+ const added = await setupProviderChoice(next);
202
+ if (added)
203
+ configs.push(added);
204
+ pickedAny = true;
205
+ }
206
+ return { providers: configs, providerStrategy: "add" };
153
207
  }
154
- const selected = await p.multiselect({
155
- message: t("provider.select"),
208
+ const firstChoice = await p.select({
209
+ message: t("provider.selectPrimary"),
156
210
  options: [
157
211
  ...entries.map(([key, info]) => ({ value: key, label: info.label, hint: info.env })),
158
212
  { value: "_custom", label: t("provider.custom"), hint: t("provider.customHint") },
159
213
  ],
160
- initialValues: ["anthropic"],
161
- required: true,
162
214
  });
163
- if (p.isCancel(selected)) {
215
+ if (p.isCancel(firstChoice)) {
164
216
  p.cancel(t("cancelled"));
165
217
  process.exit(0);
166
218
  }
167
- const configs = [];
168
- for (const name of selected) {
169
- if (name === "_custom") {
170
- const custom = await setupCustomProvider();
171
- if (custom)
172
- configs.push(custom);
173
- continue;
174
- }
175
- const info = PROVIDERS[name];
176
- const envVal = process.env[info.env];
177
- // Ask if user wants a custom endpoint for this provider
178
- const useCustomUrl = await p.confirm({
179
- message: t("provider.useCustomUrl", { label: info.label }),
219
+ selected.add(firstChoice);
220
+ const primary = await setupProviderChoice(firstChoice);
221
+ if (primary)
222
+ configs.push(primary);
223
+ // Advanced: optional fallback providers
224
+ while (true) {
225
+ const options = [
226
+ ...entries
227
+ .filter(([key]) => !selected.has(key))
228
+ .map(([key, info]) => ({ value: key, label: info.label, hint: info.env })),
229
+ ...(!selected.has("_custom") ? [{ value: "_custom", label: t("provider.custom"), hint: t("provider.customHint") }] : []),
230
+ ];
231
+ if (options.length === 0)
232
+ break;
233
+ const addFallback = await p.confirm({
234
+ message: t("provider.addFallback"),
180
235
  initialValue: false,
181
236
  });
182
- if (p.isCancel(useCustomUrl)) {
237
+ if (p.isCancel(addFallback)) {
183
238
  p.cancel(t("cancelled"));
184
239
  process.exit(0);
185
240
  }
186
- let baseUrl;
187
- if (useCustomUrl) {
188
- const url = await p.text({
189
- message: t("provider.baseUrl", { label: info.label }),
190
- placeholder: "https://proxy.example.com",
191
- validate: (v) => (!v || !v.startsWith("http")) ? t("provider.baseUrlValidation") : isUnsafeUrl(v) ? "URL must use HTTPS for remote hosts (private IPs blocked)" : undefined,
192
- });
193
- if (p.isCancel(url)) {
194
- p.cancel(t("cancelled"));
195
- process.exit(0);
196
- }
197
- baseUrl = url;
241
+ if (!addFallback)
242
+ break;
243
+ const next = await p.select({
244
+ message: t("provider.selectFallback"),
245
+ options,
246
+ });
247
+ if (p.isCancel(next)) {
248
+ p.cancel(t("cancelled"));
249
+ process.exit(0);
198
250
  }
199
- let apiKey;
200
- if (envVal && !baseUrl) {
201
- const useEnv = await p.confirm({ message: t("provider.foundEnv", { env: chalk.cyan(info.env) }) });
202
- if (p.isCancel(useEnv)) {
203
- p.cancel(t("cancelled"));
204
- process.exit(0);
205
- }
206
- apiKey = useEnv ? info.env : await promptKey(info.label);
251
+ selected.add(next);
252
+ const fallback = await setupProviderChoice(next);
253
+ if (fallback)
254
+ configs.push(fallback);
255
+ }
256
+ return { providers: configs, providerStrategy: "replace" };
257
+ }
258
+ async function setupProviderChoice(choice) {
259
+ if (choice === "_custom") {
260
+ return await setupCustomProvider();
261
+ }
262
+ const name = choice;
263
+ const info = PROVIDERS[name];
264
+ const envVal = process.env[info.env];
265
+ const useCustomUrl = await p.confirm({
266
+ message: t("provider.useCustomUrl", { label: info.label }),
267
+ initialValue: false,
268
+ });
269
+ if (p.isCancel(useCustomUrl)) {
270
+ p.cancel(t("cancelled"));
271
+ process.exit(0);
272
+ }
273
+ let baseUrl;
274
+ if (useCustomUrl) {
275
+ const url = await p.text({
276
+ message: t("provider.baseUrl", { label: info.label }),
277
+ placeholder: "https://proxy.example.com",
278
+ validate: (v) => (!v || !v.startsWith("http")) ? t("provider.baseUrlValidation") : isUnsafeUrl(v) ? "URL must use HTTPS for remote hosts (private IPs blocked)" : undefined,
279
+ });
280
+ if (p.isCancel(url)) {
281
+ p.cancel(t("cancelled"));
282
+ process.exit(0);
207
283
  }
208
- else {
209
- apiKey = await promptKey(info.label);
284
+ baseUrl = url;
285
+ }
286
+ let apiKey;
287
+ if (envVal && !baseUrl) {
288
+ const useEnv = await p.confirm({ message: t("provider.foundEnv", { env: chalk.cyan(info.env) }) });
289
+ if (p.isCancel(useEnv)) {
290
+ p.cancel(t("cancelled"));
291
+ process.exit(0);
210
292
  }
211
- // Dynamic model fetch always try
212
- const fetchUrl = baseUrl || PROVIDER_API_URLS[name];
213
- const { defaultModel, discoveredModels, api } = await selectModelWithMeta(name, info.label, info.models, fetchUrl, apiKey);
214
- configs.push({ name, apiKey, defaultModel, baseUrl, api, discoveredModels });
215
- p.log.success(t("provider.configured", { label: info.label }));
293
+ apiKey = useEnv ? info.env : await promptKey(info.label);
294
+ }
295
+ else {
296
+ apiKey = await promptKey(info.label);
297
+ }
298
+ const fetchUrl = baseUrl || PROVIDER_API_URLS[name];
299
+ const { defaultModel, discoveredModels, api } = await selectModelWithMeta(name, info.label, info.models, fetchUrl, apiKey);
300
+ let finalApi = api;
301
+ if (name === "openai") {
302
+ finalApi = await selectOpenAIApiMode(info.label, defaultModel);
303
+ }
304
+ p.log.success(t("provider.configured", { label: info.label }));
305
+ return { name, apiKey, defaultModel, baseUrl, api: finalApi, discoveredModels };
306
+ }
307
+ async function selectOpenAIApiMode(label, defaultModel) {
308
+ const selected = await p.select({
309
+ message: t("provider.apiMode", { label }),
310
+ options: [
311
+ { value: "auto", label: t("provider.apiModeAuto"), hint: t("provider.apiModeAutoHint", { model: defaultModel }) },
312
+ { value: "openai-responses", label: t("provider.apiModeResponses"), hint: t("provider.apiModeResponsesHint") },
313
+ { value: "openai-completions", label: t("provider.apiModeCompletions"), hint: t("provider.apiModeCompletionsHint") },
314
+ ],
315
+ });
316
+ if (p.isCancel(selected)) {
317
+ p.cancel(t("cancelled"));
318
+ process.exit(0);
216
319
  }
217
- return configs;
320
+ return resolveOpenAIApiMode(selected, defaultModel);
218
321
  }
219
322
  /**
220
323
  * Interactively configure a custom provider (Ollama, vLLM, or other OpenAI-compatible endpoints).
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect } from "vitest";
2
- import { isUnsafeUrl } from "./provider-setup.js";
2
+ import { isUnsafeUrl, 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);
@@ -38,3 +38,17 @@ describe("isUnsafeUrl", () => {
38
38
  expect(isUnsafeUrl("")).toBe(true);
39
39
  });
40
40
  });
41
+ describe("resolveOpenAIApiMode", () => {
42
+ it("keeps explicit responses mode", () => {
43
+ expect(resolveOpenAIApiMode("openai-responses", "gpt-4o")).toBe("openai-responses");
44
+ });
45
+ it("keeps explicit completions mode", () => {
46
+ expect(resolveOpenAIApiMode("openai-completions", "o3-mini")).toBe("openai-completions");
47
+ });
48
+ it("auto resolves o-series to responses", () => {
49
+ expect(resolveOpenAIApiMode("auto", "o3-mini")).toBe("openai-responses");
50
+ });
51
+ it("auto resolves gpt-4o to completions", () => {
52
+ expect(resolveOpenAIApiMode("auto", "gpt-4o")).toBe("openai-completions");
53
+ });
54
+ });
package/dist/types.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  /** 支持的语言区域 */
2
2
  export type Locale = "en" | "zh" | "fr";
3
+ export type ProviderSetupStrategy = "keep" | "replace" | "add";
3
4
  /** 动态发现的模型信息 */
4
5
  export interface DiscoveredModel {
5
6
  id: string;
@@ -35,6 +36,8 @@ export interface ProviderConfig {
35
36
  export interface OhPConfig {
36
37
  /** 已配置的提供商列表 */
37
38
  providers: ProviderConfig[];
39
+ /** Provider 配置策略:保留现有、替换,或在现有基础上新增 */
40
+ providerStrategy?: ProviderSetupStrategy;
38
41
  /** 主题名称 */
39
42
  theme: string;
40
43
  /** 快捷键方案 */
@@ -12,18 +12,34 @@ If the ant_colony tool is available, automatically use it when the task is compl
12
12
  For simple one-file tasks, execute directly without colony overhead.
13
13
  After launching ant_colony, use passive mode: wait for COLONY_SIGNAL updates and do not poll bg_colony_status unless the user explicitly asks for a manual snapshot.
14
14
  `;
15
+ function readJson(file) {
16
+ try {
17
+ return JSON.parse(readFileSync(file, "utf8"));
18
+ }
19
+ catch {
20
+ return null;
21
+ }
22
+ }
15
23
  /** 步骤 1-2: 生成 auth.json + settings.json */
16
24
  export function writeProviderEnv(agentDir, config) {
25
+ if (config.providerStrategy === "keep")
26
+ return;
27
+ const strategy = config.providerStrategy ?? "replace";
28
+ const authPath = join(agentDir, "auth.json");
29
+ const settingsPath = join(agentDir, "settings.json");
17
30
  // auth.json
18
31
  const authProviders = config.providers.filter(p => !p.baseUrl && p.apiKey !== "none");
19
32
  if (authProviders.length > 0) {
20
- const auth = {};
33
+ const auth = strategy === "add" ? (readJson(authPath) ?? {}) : {};
21
34
  for (const p of authProviders) {
22
35
  auth[p.name] = { type: "api_key", key: p.apiKey };
23
36
  }
24
- writeFileSync(join(agentDir, "auth.json"), JSON.stringify(auth, null, 2), { mode: 0o600 });
37
+ writeFileSync(authPath, JSON.stringify(auth, null, 2), { mode: 0o600 });
25
38
  }
26
39
  // settings.json
40
+ const existingSettings = strategy === "add"
41
+ ? (readJson(settingsPath) ?? {})
42
+ : {};
27
43
  const primary = config.providers.find(p => p.baseUrl && p.defaultModel) ?? config.providers[0];
28
44
  const providerInfo = primary ? PROVIDERS[primary.name] : undefined;
29
45
  const primaryModelId = primary?.defaultModel ?? providerInfo?.models[0];
@@ -32,8 +48,14 @@ export function writeProviderEnv(agentDir, config) {
32
48
  const reserveTokens = Math.max(16384, Math.round(ctxWindow * 0.15));
33
49
  const keepRecentTokens = Math.max(16384, Math.round(ctxWindow * 0.15));
34
50
  const primaryModel = primary?.defaultModel ?? providerInfo?.models[0];
51
+ const defaultProviderModel = strategy === "add"
52
+ ? ((!existingSettings.defaultProvider && primary)
53
+ ? { defaultProvider: primary.name, defaultModel: primaryModel }
54
+ : {})
55
+ : (primary ? { defaultProvider: primary.name, defaultModel: primaryModel } : {});
35
56
  const settings = {
36
- ...(primary ? { defaultProvider: primary.name, defaultModel: primaryModel } : {}),
57
+ ...existingSettings,
58
+ ...defaultProviderModel,
37
59
  defaultThinkingLevel: config.thinking,
38
60
  theme: config.theme,
39
61
  enableSkillCommands: true,
@@ -41,26 +63,50 @@ export function writeProviderEnv(agentDir, config) {
41
63
  retry: { enabled: true, maxRetries: 3 },
42
64
  quietStartup: true,
43
65
  };
44
- if (config.providers.length > 1) {
45
- settings.enabledModels = config.providers.flatMap((p) => {
46
- if (p.discoveredModels?.length)
47
- return p.discoveredModels.map(m => m.id);
48
- const info = PROVIDERS[p.name];
49
- return info ? info.models : [];
50
- });
66
+ const nextEnabledModels = config.providers.flatMap((p) => {
67
+ if (p.discoveredModels?.length)
68
+ return p.discoveredModels.map(m => m.id);
69
+ const info = PROVIDERS[p.name];
70
+ return info ? info.models : [];
71
+ });
72
+ if (strategy === "add") {
73
+ const current = Array.isArray(existingSettings.enabledModels)
74
+ ? existingSettings.enabledModels
75
+ : [];
76
+ const merged = [...new Set([...current, ...nextEnabledModels])];
77
+ if (merged.length > 0)
78
+ settings.enabledModels = merged;
79
+ }
80
+ else if (config.providers.length > 1) {
81
+ settings.enabledModels = nextEnabledModels;
51
82
  }
52
- writeFileSync(join(agentDir, "settings.json"), JSON.stringify(settings, null, 2));
83
+ writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
53
84
  }
54
85
  /** 步骤 3: 生成 models.json(自定义端点) */
55
86
  export function writeModelConfig(agentDir, config) {
56
- const customProviders = config.providers.filter(p => p.baseUrl);
57
- if (customProviders.length === 0)
87
+ if (config.providerStrategy === "keep")
58
88
  return;
59
- const providers = {};
60
- for (const cp of customProviders) {
89
+ const strategy = config.providerStrategy ?? "replace";
90
+ const modelsPath = join(agentDir, "models.json");
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)
94
+ return;
95
+ const providers = strategy === "add"
96
+ ? (readJson(modelsPath)?.providers ?? {})
97
+ : {};
98
+ for (const cp of modelProviders) {
61
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;
62
106
  if (isBuiltin && !cp.discoveredModels?.length) {
63
107
  const entry = { baseUrl: cp.baseUrl };
108
+ if (cp.api)
109
+ entry.api = cp.api;
64
110
  if (cp.apiKey !== "none")
65
111
  entry.apiKey = cp.apiKey;
66
112
  providers[cp.name] = entry;
@@ -93,7 +139,7 @@ export function writeModelConfig(agentDir, config) {
93
139
  providers[cp.name] = entry;
94
140
  }
95
141
  }
96
- writeFileSync(join(agentDir, "models.json"), JSON.stringify({ providers }, null, 2));
142
+ writeFileSync(modelsPath, JSON.stringify({ providers }, null, 2));
97
143
  }
98
144
  /** 步骤 4: 生成 keybindings.json */
99
145
  export function writeKeybindings(agentDir, config) {
@@ -1,8 +1,8 @@
1
1
  import { describe, it, expect, afterEach } from "vitest";
2
- import { mkdtempSync, readFileSync, rmSync } from "node:fs";
2
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync, existsSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
- import { writeAgents } from "./writers.js";
5
+ import { writeAgents, writeProviderEnv, writeModelConfig } from "./writers.js";
6
6
  const tempDirs = [];
7
7
  function makeTempDir() {
8
8
  const dir = mkdtempSync(join(tmpdir(), "oh-pi-writers-"));
@@ -58,3 +58,144 @@ describe("writeAgents", () => {
58
58
  expect(content).toContain("You command an autonomous ant colony");
59
59
  });
60
60
  });
61
+ describe("provider keep strategy", () => {
62
+ it("writeProviderEnv does not touch settings/auth when strategy is keep", () => {
63
+ const dir = makeTempDir();
64
+ const settingsPath = join(dir, "settings.json");
65
+ const authPath = join(dir, "auth.json");
66
+ const originalSettings = JSON.stringify({ defaultProvider: "openai", defaultModel: "gpt-4o" }, null, 2);
67
+ const originalAuth = JSON.stringify({ openai: { type: "api_key", key: "OPENAI_API_KEY" } }, null, 2);
68
+ writeFileSync(settingsPath, originalSettings);
69
+ writeFileSync(authPath, originalAuth);
70
+ writeProviderEnv(dir, makeConfig({
71
+ providerStrategy: "keep",
72
+ providers: [],
73
+ }));
74
+ expect(readFileSync(settingsPath, "utf8")).toBe(originalSettings);
75
+ expect(readFileSync(authPath, "utf8")).toBe(originalAuth);
76
+ });
77
+ it("writeModelConfig does not touch models.json when strategy is keep", () => {
78
+ const dir = makeTempDir();
79
+ const modelsPath = join(dir, "models.json");
80
+ const originalModels = JSON.stringify({
81
+ providers: {
82
+ openai: { baseUrl: "https://api.openai.com", api: "openai-responses" },
83
+ },
84
+ }, null, 2);
85
+ writeFileSync(modelsPath, originalModels);
86
+ writeModelConfig(dir, makeConfig({
87
+ providerStrategy: "keep",
88
+ providers: [],
89
+ }));
90
+ expect(readFileSync(modelsPath, "utf8")).toBe(originalModels);
91
+ });
92
+ it("writeModelConfig creates custom provider entries when replacing", () => {
93
+ const dir = makeTempDir();
94
+ writeModelConfig(dir, makeConfig({
95
+ providerStrategy: "replace",
96
+ providers: [{
97
+ name: "custom-openai",
98
+ apiKey: "OPENAI_API_KEY",
99
+ baseUrl: "https://api.openai.com",
100
+ defaultModel: "gpt-4o",
101
+ api: "openai-responses",
102
+ }],
103
+ }));
104
+ const modelsPath = join(dir, "models.json");
105
+ expect(existsSync(modelsPath)).toBe(true);
106
+ const text = readFileSync(modelsPath, "utf8");
107
+ expect(text).toContain("\"custom-openai\"");
108
+ expect(text).toContain("\"openai-responses\"");
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
+ });
145
+ it("writeProviderEnv merges auth/settings when strategy is add", () => {
146
+ const dir = makeTempDir();
147
+ const settingsPath = join(dir, "settings.json");
148
+ const authPath = join(dir, "auth.json");
149
+ writeFileSync(settingsPath, JSON.stringify({
150
+ defaultProvider: "anthropic",
151
+ defaultModel: "claude-sonnet-4-20250514",
152
+ enabledModels: ["claude-sonnet-4-20250514"],
153
+ theme: "dark",
154
+ }, null, 2));
155
+ writeFileSync(authPath, JSON.stringify({
156
+ anthropic: { type: "api_key", key: "ANTHROPIC_API_KEY" },
157
+ }, null, 2));
158
+ writeProviderEnv(dir, makeConfig({
159
+ providerStrategy: "add",
160
+ theme: "light",
161
+ providers: [{
162
+ name: "openai",
163
+ apiKey: "OPENAI_API_KEY",
164
+ defaultModel: "gpt-4o",
165
+ discoveredModels: [{ id: "gpt-4o", reasoning: false, input: ["text", "image"], contextWindow: 128000, maxTokens: 16384 }],
166
+ }],
167
+ }));
168
+ const settings = JSON.parse(readFileSync(settingsPath, "utf8"));
169
+ const auth = JSON.parse(readFileSync(authPath, "utf8"));
170
+ expect(settings.defaultProvider).toBe("anthropic");
171
+ expect(settings.defaultModel).toBe("claude-sonnet-4-20250514");
172
+ expect(settings.theme).toBe("light");
173
+ expect(settings.enabledModels).toContain("claude-sonnet-4-20250514");
174
+ expect(settings.enabledModels).toContain("gpt-4o");
175
+ expect(auth.anthropic).toBeTruthy();
176
+ expect(auth.openai).toEqual({ type: "api_key", key: "OPENAI_API_KEY" });
177
+ });
178
+ it("writeModelConfig merges custom providers when strategy is add", () => {
179
+ const dir = makeTempDir();
180
+ const modelsPath = join(dir, "models.json");
181
+ writeFileSync(modelsPath, JSON.stringify({
182
+ providers: {
183
+ existing: { baseUrl: "https://example.com/v1", api: "openai-completions" },
184
+ },
185
+ }, null, 2));
186
+ writeModelConfig(dir, makeConfig({
187
+ providerStrategy: "add",
188
+ providers: [{
189
+ name: "custom-openai",
190
+ apiKey: "OPENAI_API_KEY",
191
+ baseUrl: "https://api.openai.com",
192
+ defaultModel: "gpt-4o",
193
+ api: "openai-responses",
194
+ }],
195
+ }));
196
+ const models = JSON.parse(readFileSync(modelsPath, "utf8"));
197
+ expect(models.providers.existing).toBeTruthy();
198
+ expect(models.providers["custom-openai"]).toBeTruthy();
199
+ expect(JSON.stringify(models.providers["custom-openai"])).toContain("openai-responses");
200
+ });
201
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oh-pi",
3
- "version": "0.1.78",
3
+ "version": "0.1.80",
4
4
  "description": "One-click setup for pi-coding-agent. Like oh-my-zsh for pi.",
5
5
  "type": "module",
6
6
  "bin": {