@heventure/model-provider-x 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -8,6 +8,7 @@ It currently supports:
8
8
  - Claude Code setup through a local Anthropic Messages-compatible proxy.
9
9
  - OpenAI-compatible `/v1/models` discovery.
10
10
  - Anthropic Messages API to OpenAI Chat Completions API conversion.
11
+ - OpenAI Responses API to OpenAI Chat Completions API conversion for Codex.
11
12
  - Non-streaming and streaming SSE proxy responses.
12
13
 
13
14
  ## Install
@@ -56,19 +57,65 @@ node dist/cli/index.js setup --target claude-code \
56
57
  Then start the local proxy:
57
58
 
58
59
  ```bash
59
- node dist/cli/index.js proxy --profile unsloth
60
+ node dist/cli/index.js proxy up --profile unsloth
60
61
  ```
61
62
 
62
63
  The Claude Code setup writes gateway environment values to `~/.claude/settings.json`.
63
64
  Upstream provider keys are stored in `~/.config/model-provider-x/config.jsonc`, not in Claude Code settings.
64
65
 
66
+ ## Codex Setup
67
+
68
+ Create or update a provider profile and write Codex user config:
69
+
70
+ ```bash
71
+ node dist/cli/index.js setup --target codex \
72
+ --provider lmstudio
73
+ ```
74
+
75
+ Then start the local proxy:
76
+
77
+ ```bash
78
+ node dist/cli/index.js proxy up --profile lmstudio
79
+ ```
80
+
81
+ The Codex setup writes a Responses-compatible provider to `~/.codex/config.toml`.
82
+ It also configures command-backed authentication so Codex can fetch the local proxy token automatically.
83
+ The proxy currently supports non-streaming `/v1/responses` requests and forwards them to upstream OpenAI-compatible `/v1/chat/completions`.
84
+
85
+ ## Unified Setup Flow
86
+
87
+ Use the unified setup wizard to install a provider into OpenCode, Codex, or Claude Code:
88
+
89
+ ```bash
90
+ node dist/cli/index.js setup --provider lmstudio
91
+ ```
92
+
93
+ The wizard asks whether to write the agent config through the local compatibility proxy before choosing the target agent.
94
+ Proxy mode gives the broadest compatibility:
95
+
96
+ - `/v1/responses` for Codex.
97
+ - `/v1/chat/completions` and `/v1/completions` passthrough for OpenAI-compatible clients.
98
+ - `/v1/messages` for Claude Code.
99
+
100
+ You can force either mode non-interactively:
101
+
102
+ ```bash
103
+ node dist/cli/index.js setup --target codex --provider lmstudio --proxy
104
+ node dist/cli/index.js setup --target opencode --provider lmstudio --direct
105
+ ```
106
+
65
107
  ## Commands
66
108
 
67
109
  ```bash
68
110
  npx @heventure/model-provider-x --help
69
111
  npx @heventure/model-provider-x setup --target claude-code
112
+ npx @heventure/model-provider-x setup --target codex
70
113
  npx @heventure/model-provider-x proxy --profile <id>
114
+ npx @heventure/model-provider-x proxy up --profile <id>
115
+ npx @heventure/model-provider-x proxy status
116
+ npx @heventure/model-provider-x proxy down
71
117
  npx @heventure/model-provider-x config print --profile <id>
118
+ npx @heventure/model-provider-x config api-key --profile <id>
72
119
  ```
73
120
 
74
121
  ## Development
package/dist/cli/args.js CHANGED
@@ -35,6 +35,12 @@ export function parseCliArgs(argv) {
35
35
  case "--provider":
36
36
  options.providerPreset = next();
37
37
  break;
38
+ case "--proxy":
39
+ options.proxy = true;
40
+ break;
41
+ case "--direct":
42
+ options.proxy = false;
43
+ break;
38
44
  case "--print":
39
45
  options.print = true;
40
46
  break;
@@ -93,6 +99,8 @@ Options:
93
99
  --name <name> Provider display name.
94
100
  --id <id> Provider id used under provider.<id>.
95
101
  --provider <id> Use a built-in provider preset, for example lmstudio or openai.
102
+ --proxy Write agent config through the local compatibility proxy.
103
+ --direct Write agent config directly to the upstream provider.
96
104
  --models <list> Comma-separated model ids. Skips interactive model selection.
97
105
  --config <path> OpenCode config path to write.
98
106
  --print Print generated JSON and do not write config.
@@ -21,13 +21,18 @@ export function parseCommand(argv) {
21
21
  export function commandUsage() {
22
22
  return `${usage()}
23
23
  Commands:
24
- model-provider-x setup --target <opencode|claude-code> [options]
24
+ model-provider-x setup --target <opencode|claude-code|codex> [options]
25
25
  model-provider-x proxy --profile <id> [--host 127.0.0.1] [--port 4141]
26
+ model-provider-x proxy up --profile <id> [--host 127.0.0.1] [--port 4141]
27
+ model-provider-x proxy down
28
+ model-provider-x proxy status
29
+ model-provider-x proxy token
26
30
  model-provider-x config print --profile <id>
31
+ model-provider-x config api-key --profile <id>
27
32
  `;
28
33
  }
29
34
  function parseSetupCommand(argv) {
30
- let target = "opencode";
35
+ let target;
31
36
  let profileId;
32
37
  let port;
33
38
  let host;
@@ -66,13 +71,18 @@ function parseSetupCommand(argv) {
66
71
  return { command: "setup", target, profileId, port, host, defaultModel, options: parseCliArgs(providerArgs) };
67
72
  }
68
73
  function parseProxyCommand(argv) {
74
+ let action = "run";
75
+ const args = [...argv];
76
+ if (args[0] === "up" || args[0] === "down" || args[0] === "status" || args[0] === "token") {
77
+ action = args.shift();
78
+ }
69
79
  let profileId;
70
80
  let host;
71
81
  let port;
72
- for (let index = 0; index < argv.length; index += 1) {
73
- const arg = argv[index];
82
+ for (let index = 0; index < args.length; index += 1) {
83
+ const arg = args[index];
74
84
  const next = () => {
75
- const value = argv[index + 1];
85
+ const value = args[index + 1];
76
86
  if (!value || value.startsWith("--")) {
77
87
  throw new Error(`${arg} requires a value`);
78
88
  }
@@ -96,14 +106,14 @@ function parseProxyCommand(argv) {
96
106
  throw new Error(`Unknown proxy argument: ${arg}`);
97
107
  }
98
108
  }
99
- if (!profileId) {
109
+ if ((action === "run" || action === "up") && !profileId) {
100
110
  throw new Error("--profile is required");
101
111
  }
102
- return { command: "proxy", profileId, host, port };
112
+ return { command: "proxy", action, profileId, host, port };
103
113
  }
104
114
  function parseConfigCommand(argv) {
105
115
  const [subcommand, ...rest] = argv;
106
- if (subcommand !== "print") {
116
+ if (subcommand !== "print" && subcommand !== "api-key") {
107
117
  throw new Error(`Unknown config command: ${subcommand ?? ""}`.trim());
108
118
  }
109
119
  let profileId;
@@ -122,10 +132,10 @@ function parseConfigCommand(argv) {
122
132
  if (!profileId) {
123
133
  throw new Error("--profile is required");
124
134
  }
125
- return { command: "config-print", profileId };
135
+ return { command: subcommand === "api-key" ? "config-api-key" : "config-print", profileId };
126
136
  }
127
137
  function parseTarget(value) {
128
- if (value === "opencode" || value === "claude-code") {
138
+ if (value === "opencode" || value === "claude-code" || value === "codex") {
129
139
  return value;
130
140
  }
131
141
  throw new Error(`Unknown setup target: ${value}`);
package/dist/cli/index.js CHANGED
@@ -1,11 +1,14 @@
1
1
  #!/usr/bin/env node
2
2
  import { createInterface } from "node:readline/promises";
3
3
  import { stdin as input, stdout as output } from "node:process";
4
+ import { fileURLToPath } from "node:url";
5
+ import { readProxyStatus, startProxyProcess, stopProxyProcess } from "../core/proxy-process.js";
4
6
  import { getDefaultToolConfigPath, readToolConfig, upsertProviderProfile } from "../core/tool-config.js";
5
7
  import { discoverOpenCodeConfigs, getDefaultConfigPath, writeProviderToConfig } from "../core/config.js";
6
- import { buildProviderConfig, validateAndFetchModels } from "../core/provider.js";
8
+ import { buildProviderConfig, detectProviderCapabilities, recommendProxyMode, validateAndFetchModels } from "../core/provider.js";
7
9
  import { startProxyServer } from "../proxy/server.js";
8
10
  import { getDefaultClaudeSettingsPath, writeClaudeCodeSettings } from "../targets/claude-code.js";
11
+ import { getDefaultCodexConfigPath, writeCodexConfig } from "../targets/codex.js";
9
12
  import { HelpRequested, parseModelSelection } from "./args.js";
10
13
  import { commandUsage, parseCommand } from "./commands.js";
11
14
  import { createModelChoices } from "./model-choices.js";
@@ -43,22 +46,16 @@ export async function runCommand(command) {
43
46
  output.write(`${JSON.stringify(profile, null, 2)}\n`);
44
47
  return;
45
48
  }
46
- const config = await readToolConfig();
47
- const server = await startProxyServer({
48
- profileId: command.profileId,
49
- config,
50
- host: command.host,
51
- port: command.port
52
- });
53
- output.write(`model-provider-x proxy listening at ${server.baseURL}\n`);
54
- await new Promise((resolve) => {
55
- const stop = async () => {
56
- await server.close();
57
- resolve();
58
- };
59
- process.once("SIGINT", stop);
60
- process.once("SIGTERM", stop);
61
- });
49
+ if (command.command === "config-api-key") {
50
+ const config = await readToolConfig();
51
+ const profile = config.profiles[command.profileId];
52
+ if (!profile?.apiKey) {
53
+ throw new Error(`No API key stored for provider profile: ${command.profileId}`);
54
+ }
55
+ output.write(`${profile.apiKey}\n`);
56
+ return;
57
+ }
58
+ await runProxyCommand(command);
62
59
  }
63
60
  export async function runCli(options) {
64
61
  const rl = createInterface({ input, output });
@@ -107,59 +104,211 @@ export async function runCli(options) {
107
104
  }
108
105
  }
109
106
  async function runSetup(command) {
110
- if (command.target === "opencode") {
111
- await runCli(command.options);
112
- return;
113
- }
114
- await runClaudeCodeSetup(command);
115
- }
116
- async function runClaudeCodeSetup(command) {
117
107
  const rl = createInterface({ input, output });
118
108
  try {
119
109
  output.write(canUseTui() ? renderIntro() : "model-provider-x\n\n");
120
- const providerDefaults = await resolveProviderDefaults(rl, command.options);
121
- const providerName = await requiredOption(rl, command.options.providerName ?? providerDefaults.name, "Provider name");
122
- const providerId = await requiredOption(rl, command.profileId ?? command.options.providerId ?? providerDefaults.id ?? slugify(providerName), "Provider id");
123
- const baseURL = await requiredOption(rl, command.options.baseURL ?? providerDefaults.baseURL, "API base URL");
124
- const apiKey = await resolveApiKey(rl, command.options.apiKey, providerDefaults.preset, "Upstream API key");
125
- output.write("Fetching models...\n");
126
- const fetched = await validateAndFetchModels({ baseURL, apiKey });
127
- const selectedModels = command.options.models ??
128
- (canUseTui()
129
- ? await multiSelectChoices("Select Claude Code gateway models", createModelChoices(fetched.models))
130
- : parseModelSelection(await rl.question(formatModelPrompt(fetched.models)), fetched.models));
131
- const toolConfigPath = getDefaultToolConfigPath();
132
- const config = await upsertProviderProfile(toolConfigPath, {
133
- id: providerId,
134
- name: providerName,
135
- baseURL: fetched.baseURL,
136
- apiKey: apiKey.trim() || undefined,
137
- models: selectedModels
138
- }, {
139
- host: command.host,
140
- port: command.port
141
- });
142
- const proxyBaseURL = `http://${config.proxy.host}:${config.proxy.port}`;
143
- const result = await writeClaudeCodeSettings({
144
- targetPath: getDefaultClaudeSettingsPath(),
145
- proxy: {
146
- baseURL: proxyBaseURL,
147
- authToken: config.proxy.authToken,
148
- enableModelDiscovery: true,
149
- defaultModel: command.defaultModel
150
- }
151
- });
152
- output.write(`Saved profile ${providerId} to ${toolConfigPath}\n`);
153
- output.write(`Updated Claude Code settings: ${result.targetPath}\n`);
154
- if (result.backupPath) {
155
- output.write(`Backup: ${result.backupPath}\n`);
110
+ const providerInput = await collectProviderInput(rl, command);
111
+ output.write("Detecting provider capabilities...\n");
112
+ const capabilities = await detectProviderCapabilities({ baseURL: providerInput.baseURL, apiKey: providerInput.apiKey });
113
+ const target = await resolveSetupTarget(rl, command.target);
114
+ const useProxy = await resolveProxyMode(rl, command.options, capabilities, target);
115
+ const selection = await collectProviderSelection(rl, command, target, providerInput);
116
+ if (target === "opencode") {
117
+ await writeOpenCodeSetup(rl, command, selection, useProxy);
118
+ return;
156
119
  }
157
- output.write(`Run proxy: model-provider-x proxy --profile ${providerId}\n`);
120
+ if (target === "codex") {
121
+ await writeCodexSetup(command, selection, useProxy);
122
+ return;
123
+ }
124
+ await writeClaudeCodeSetup(command, selection, useProxy);
158
125
  }
159
126
  finally {
160
127
  rl.close();
161
128
  }
162
129
  }
130
+ async function collectProviderInput(rl, command) {
131
+ const providerDefaults = await resolveProviderDefaults(rl, command.options);
132
+ const providerName = await requiredOption(rl, command.options.providerName ?? providerDefaults.name, "Provider name");
133
+ const providerId = await requiredOption(rl, command.profileId ?? command.options.providerId ?? providerDefaults.id ?? slugify(providerName), "Provider id");
134
+ const baseURL = await requiredOption(rl, command.options.baseURL ?? providerDefaults.baseURL, "API base URL");
135
+ const apiKey = await resolveApiKey(rl, command.options.apiKey, providerDefaults.preset, "Upstream API key");
136
+ return { providerId, providerName, baseURL, apiKey };
137
+ }
138
+ async function collectProviderSelection(rl, command, target, providerInput) {
139
+ output.write("Fetching models...\n");
140
+ const fetched = await validateAndFetchModels({ baseURL: providerInput.baseURL, apiKey: providerInput.apiKey });
141
+ const selectedModels = command.options.models ??
142
+ (canUseTui()
143
+ ? await multiSelectChoices(target === "codex"
144
+ ? "Select Codex Responses gateway models"
145
+ : target === "claude-code"
146
+ ? "Select Claude Code gateway models"
147
+ : "Select models", createModelChoices(fetched.models))
148
+ : parseModelSelection(await rl.question(formatModelPrompt(fetched.models)), fetched.models));
149
+ const defaultModel = command.defaultModel ?? selectedModels[0];
150
+ if (!defaultModel) {
151
+ throw new Error("Select at least one model");
152
+ }
153
+ const toolConfigPath = getDefaultToolConfigPath();
154
+ const config = await upsertProviderProfile(toolConfigPath, {
155
+ id: providerInput.providerId,
156
+ name: providerInput.providerName,
157
+ baseURL: fetched.baseURL,
158
+ apiKey: providerInput.apiKey.trim() || undefined,
159
+ models: selectedModels
160
+ }, {
161
+ host: command.host,
162
+ port: command.port
163
+ });
164
+ return {
165
+ providerId: providerInput.providerId,
166
+ providerName: providerInput.providerName,
167
+ upstreamBaseURL: fetched.baseURL,
168
+ apiKey: providerInput.apiKey,
169
+ selectedModels,
170
+ defaultModel,
171
+ config,
172
+ toolConfigPath
173
+ };
174
+ }
175
+ async function writeOpenCodeSetup(rl, command, selection, useProxy) {
176
+ const baseURL = useProxy ? `http://${selection.config.proxy.host}:${selection.config.proxy.port}/v1` : selection.upstreamBaseURL;
177
+ const apiKey = useProxy ? selection.config.proxy.authToken : selection.apiKey;
178
+ const fragment = buildProviderConfig({
179
+ providerId: selection.providerId,
180
+ providerName: selection.providerName,
181
+ baseURL,
182
+ apiKey,
183
+ models: selection.selectedModels
184
+ });
185
+ const provider = fragment.provider[selection.providerId];
186
+ const targetPath = command.options.configPath ?? (await chooseConfigPath(rl, selection.providerId, command.options.yes));
187
+ if (!targetPath) {
188
+ output.write(`${JSON.stringify(fragment, null, 2)}\n`);
189
+ return;
190
+ }
191
+ const result = await writeProviderToConfig({ targetPath, providerId: selection.providerId, provider });
192
+ output.write(`Saved profile ${selection.providerId} to ${selection.toolConfigPath}\n`);
193
+ output.write(`Updated ${result.targetPath}\n`);
194
+ if (result.backupPath) {
195
+ output.write(`Backup: ${result.backupPath}\n`);
196
+ }
197
+ if (useProxy) {
198
+ const proxy = await startProxyProcess({
199
+ profileId: selection.providerId,
200
+ config: selection.config,
201
+ entrypoint: fileURLToPath(import.meta.url),
202
+ replace: true
203
+ });
204
+ output.write(`Started proxy: ${proxy.baseURL}\n`);
205
+ }
206
+ }
207
+ async function writeClaudeCodeSetup(command, selection, useProxy) {
208
+ const proxyBaseURL = useProxy ? `http://${selection.config.proxy.host}:${selection.config.proxy.port}` : selection.upstreamBaseURL;
209
+ const result = await writeClaudeCodeSettings({
210
+ targetPath: getDefaultClaudeSettingsPath(),
211
+ proxy: {
212
+ baseURL: proxyBaseURL,
213
+ authToken: useProxy ? selection.config.proxy.authToken : selection.apiKey,
214
+ enableModelDiscovery: useProxy,
215
+ defaultModel: command.defaultModel
216
+ }
217
+ });
218
+ output.write(`Saved profile ${selection.providerId} to ${selection.toolConfigPath}\n`);
219
+ output.write(`Updated Claude Code settings: ${result.targetPath}\n`);
220
+ if (result.backupPath) {
221
+ output.write(`Backup: ${result.backupPath}\n`);
222
+ }
223
+ if (useProxy) {
224
+ const proxy = await startProxyProcess({
225
+ profileId: selection.providerId,
226
+ config: selection.config,
227
+ entrypoint: fileURLToPath(import.meta.url),
228
+ replace: true
229
+ });
230
+ output.write(`Started proxy: ${proxy.baseURL}\n`);
231
+ }
232
+ }
233
+ async function writeCodexSetup(_command, selection, useProxy) {
234
+ const proxyBaseURL = useProxy
235
+ ? `http://${selection.config.proxy.host}:${selection.config.proxy.port}/v1`
236
+ : selection.upstreamBaseURL;
237
+ const result = await writeCodexConfig({
238
+ targetPath: getDefaultCodexConfigPath(),
239
+ providerId: selection.providerId,
240
+ providerName: selection.providerName,
241
+ baseURL: proxyBaseURL,
242
+ authCommand: "model-provider-x",
243
+ authArgs: useProxy ? ["proxy", "token"] : ["config", "api-key", "--profile", selection.providerId],
244
+ model: selection.defaultModel
245
+ });
246
+ output.write(`Saved profile ${selection.providerId} to ${selection.toolConfigPath}\n`);
247
+ output.write(`Updated Codex config: ${result.targetPath}\n`);
248
+ if (result.backupPath) {
249
+ output.write(`Backup: ${result.backupPath}\n`);
250
+ }
251
+ if (useProxy) {
252
+ const proxy = await startProxyProcess({
253
+ profileId: selection.providerId,
254
+ config: selection.config,
255
+ entrypoint: fileURLToPath(import.meta.url),
256
+ replace: true
257
+ });
258
+ output.write(`Started proxy: ${proxy.baseURL}\n`);
259
+ }
260
+ }
261
+ async function runProxyCommand(command) {
262
+ if (command.action === "up") {
263
+ const config = await readToolConfig();
264
+ const state = await startProxyProcess({
265
+ profileId: command.profileId,
266
+ config,
267
+ host: command.host,
268
+ port: command.port,
269
+ entrypoint: fileURLToPath(import.meta.url)
270
+ });
271
+ output.write(`Proxy running at ${state.baseURL} for profile ${state.profileId}\n`);
272
+ return;
273
+ }
274
+ if (command.action === "down") {
275
+ const status = await stopProxyProcess();
276
+ output.write(status.state ? `Stopped proxy for profile ${status.state.profileId}\n` : "Proxy is not running\n");
277
+ return;
278
+ }
279
+ if (command.action === "status") {
280
+ const status = await readProxyStatus();
281
+ if (!status.state) {
282
+ output.write("Proxy is not running\n");
283
+ return;
284
+ }
285
+ output.write(status.running
286
+ ? `Proxy is running at ${status.state.baseURL} for profile ${status.state.profileId} (pid ${status.state.pid})\n`
287
+ : `Proxy state exists but process ${status.state.pid} is not running\n`);
288
+ return;
289
+ }
290
+ if (command.action === "token") {
291
+ const config = await readToolConfig();
292
+ output.write(`${config.proxy.authToken}\n`);
293
+ return;
294
+ }
295
+ const config = await readToolConfig();
296
+ const server = await startProxyServer({
297
+ profileId: command.profileId,
298
+ config,
299
+ host: command.host,
300
+ port: command.port
301
+ });
302
+ output.write(`model-provider-x proxy listening at ${server.baseURL}\n`);
303
+ await new Promise((resolve) => {
304
+ const stop = async () => {
305
+ await server.close();
306
+ resolve();
307
+ };
308
+ process.once("SIGINT", stop);
309
+ process.once("SIGTERM", stop);
310
+ });
311
+ }
163
312
  async function requiredOption(rl, value, label) {
164
313
  const answer = value ?? (await rl.question(`${label}: `));
165
314
  if (!answer.trim()) {
@@ -186,6 +335,48 @@ async function resolveProviderDefaults(rl, options) {
186
335
  preset
187
336
  };
188
337
  }
338
+ async function resolveProxyMode(rl, options, capabilities, target) {
339
+ if (options.proxy !== undefined) {
340
+ return options.proxy;
341
+ }
342
+ const recommendedProxy = recommendProxyMode(capabilities, target);
343
+ if (canUseTui()) {
344
+ return selectChoice("Connection mode", [
345
+ {
346
+ label: "Direct provider config",
347
+ value: false,
348
+ hint: recommendedProxy ? "target API not detected" : "recommended"
349
+ },
350
+ {
351
+ label: "Use compatibility proxy",
352
+ value: true,
353
+ hint: recommendedProxy ? "recommended" : "maximum compatibility"
354
+ }
355
+ ]);
356
+ }
357
+ const prompt = recommendedProxy ? "Use compatibility proxy? [Y/n] " : "Use direct provider config? [Y/n] ";
358
+ const answer = await rl.question(prompt);
359
+ const accepted = !answer.trim() || answer.trim().toLowerCase() === "y";
360
+ return recommendedProxy ? accepted : !accepted;
361
+ }
362
+ async function resolveSetupTarget(rl, target) {
363
+ if (target) {
364
+ return target;
365
+ }
366
+ if (canUseTui()) {
367
+ return selectChoice("Choose agent platform", [
368
+ { label: "OpenCode", value: "opencode" },
369
+ { label: "Codex", value: "codex" },
370
+ { label: "Claude Code", value: "claude-code" }
371
+ ]);
372
+ }
373
+ const answer = await rl.question("Agent platform [opencode/codex/claude-code]: ");
374
+ const value = answer.trim() || "opencode";
375
+ if (value === "opencode" || value === "codex" || value === "claude-code") {
376
+ return value;
377
+ }
378
+ throw new Error(`Unknown setup target: ${value}`);
379
+ }
189
380
  async function resolveApiKey(rl, apiKey, preset, label = "API key") {
190
381
  if (apiKey !== undefined) {
191
382
  return apiKey;
@@ -37,6 +37,37 @@ export async function validateAndFetchModels(input, fetchImpl = globalThis.fetch
37
37
  }
38
38
  return { baseURL, models };
39
39
  }
40
+ export async function detectProviderCapabilities(input, fetchImpl = globalThis.fetch) {
41
+ const baseURL = normalizeBaseUrl(input.baseURL);
42
+ const apiKey = input.apiKey?.trim() ?? "";
43
+ const headers = apiKey ? { Authorization: `Bearer ${apiKey}` } : {};
44
+ const apis = new Set();
45
+ const models = await probe(`${baseURL}/models`, { method: "GET", headers }, fetchImpl);
46
+ if (models.reachable) {
47
+ apis.add("openai-compatible");
48
+ }
49
+ const responses = await probe(`${baseURL}/responses`, { method: "POST", headers: { ...headers, "content-type": "application/json" }, body: "{}" }, fetchImpl);
50
+ if (responses.reachable) {
51
+ apis.add("openai-responses");
52
+ }
53
+ const messages = await probe(`${baseURL}/messages`, { method: "POST", headers: { ...headers, "content-type": "application/json" }, body: "{}" }, fetchImpl);
54
+ if (messages.reachable) {
55
+ apis.add("anthropic-messages");
56
+ }
57
+ return { baseURL, apis: [...apis] };
58
+ }
59
+ export function targetRequiredApi(target) {
60
+ if (target === "codex") {
61
+ return "openai-responses";
62
+ }
63
+ if (target === "claude-code") {
64
+ return "anthropic-messages";
65
+ }
66
+ return "openai-compatible";
67
+ }
68
+ export function recommendProxyMode(capabilities, target) {
69
+ return !capabilities.apis.includes(targetRequiredApi(target));
70
+ }
40
71
  export function buildProviderConfig(input) {
41
72
  const baseURL = normalizeBaseUrl(input.baseURL);
42
73
  const apiKey = input.apiKey?.trim();
@@ -70,3 +101,12 @@ function isOpenCodeCompatibleModel(model) {
70
101
  }
71
102
  return ["llm", "chat", "completion", "text-generation"].includes(model.type.trim().toLowerCase());
72
103
  }
104
+ async function probe(input, init, fetchImpl) {
105
+ try {
106
+ const response = await fetchImpl(input, init);
107
+ return { reachable: response.ok || response.status === 400 || response.status === 401 || response.status === 422 };
108
+ }
109
+ catch {
110
+ return { reachable: false };
111
+ }
112
+ }
@@ -0,0 +1,102 @@
1
+ import { mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
2
+ import { spawn } from "node:child_process";
3
+ import { dirname, join } from "node:path";
4
+ import { homedir } from "node:os";
5
+ export function getDefaultProxyStatePath(homeDir = homedir()) {
6
+ return join(homeDir, ".config", "model-provider-x", "proxy.json");
7
+ }
8
+ export async function readProxyStatus(path = getDefaultProxyStatePath()) {
9
+ const state = await readProxyState(path);
10
+ if (!state) {
11
+ return { running: false };
12
+ }
13
+ return { running: isProcessRunning(state.pid), state };
14
+ }
15
+ export async function startProxyProcess(input) {
16
+ const profile = input.config.profiles[input.profileId];
17
+ if (!profile) {
18
+ throw new Error(`Unknown provider profile: ${input.profileId}`);
19
+ }
20
+ const statePath = input.statePath ?? getDefaultProxyStatePath();
21
+ const current = await readProxyStatus(statePath);
22
+ if (current.running && current.state?.profileId === input.profileId) {
23
+ return current.state;
24
+ }
25
+ if (current.running && current.state) {
26
+ if (input.replace) {
27
+ await stopProxyProcess(statePath);
28
+ }
29
+ else {
30
+ throw new Error(`Proxy is already running for profile ${current.state.profileId}`);
31
+ }
32
+ }
33
+ const host = input.host ?? input.config.proxy.host;
34
+ const port = input.port ?? input.config.proxy.port;
35
+ const entrypoint = input.entrypoint ?? process.argv[1];
36
+ const child = spawn(input.nodePath ?? process.execPath, [entrypoint, "proxy", "--profile", input.profileId, "--host", host, "--port", String(port)], {
37
+ detached: true,
38
+ stdio: "ignore",
39
+ env: { ...process.env, MODEL_PROVIDER_X_API_KEY: input.config.proxy.authToken }
40
+ });
41
+ child.unref();
42
+ const state = {
43
+ pid: child.pid ?? 0,
44
+ profileId: input.profileId,
45
+ baseURL: `http://${host}:${port}`,
46
+ authToken: input.config.proxy.authToken,
47
+ startedAt: new Date().toISOString()
48
+ };
49
+ await writeProxyState(statePath, state);
50
+ return state;
51
+ }
52
+ export async function stopProxyProcess(path = getDefaultProxyStatePath()) {
53
+ const state = await readProxyState(path);
54
+ if (!state) {
55
+ return { running: false };
56
+ }
57
+ if (isProcessRunning(state.pid)) {
58
+ process.kill(state.pid, "SIGTERM");
59
+ }
60
+ await rm(path, { force: true });
61
+ return { running: false, state };
62
+ }
63
+ async function readProxyState(path) {
64
+ if (!(await fileExists(path))) {
65
+ return undefined;
66
+ }
67
+ const parsed = JSON.parse(await readFile(path, "utf8"));
68
+ if (typeof parsed.pid !== "number" || typeof parsed.profileId !== "string") {
69
+ return undefined;
70
+ }
71
+ return {
72
+ pid: parsed.pid,
73
+ profileId: parsed.profileId,
74
+ baseURL: String(parsed.baseURL ?? ""),
75
+ authToken: String(parsed.authToken ?? ""),
76
+ startedAt: String(parsed.startedAt ?? "")
77
+ };
78
+ }
79
+ async function writeProxyState(path, state) {
80
+ await mkdir(dirname(path), { recursive: true });
81
+ await writeFile(path, `${JSON.stringify(state, null, 2)}\n`, "utf8");
82
+ }
83
+ function isProcessRunning(pid) {
84
+ if (!pid) {
85
+ return false;
86
+ }
87
+ try {
88
+ process.kill(pid, 0);
89
+ return true;
90
+ }
91
+ catch {
92
+ return false;
93
+ }
94
+ }
95
+ async function fileExists(path) {
96
+ try {
97
+ return (await stat(path)).isFile();
98
+ }
99
+ catch {
100
+ return false;
101
+ }
102
+ }
@@ -0,0 +1,107 @@
1
+ export function responsesToChatCompletionRequest(request) {
2
+ if (request.stream) {
3
+ throw new Error("Responses streaming is not supported yet");
4
+ }
5
+ const messages = [];
6
+ if (request.instructions) {
7
+ messages.push({ role: "system", content: request.instructions });
8
+ }
9
+ if (typeof request.input === "string") {
10
+ messages.push({ role: "user", content: request.input });
11
+ }
12
+ else {
13
+ for (const item of request.input) {
14
+ messages.push(responsesInputItemToChatMessage(item));
15
+ }
16
+ }
17
+ return pruneUndefined({
18
+ model: request.model,
19
+ messages,
20
+ max_tokens: request.max_output_tokens,
21
+ temperature: request.temperature,
22
+ top_p: request.top_p,
23
+ tools: request.tools?.map((tool) => ({
24
+ type: "function",
25
+ function: {
26
+ name: tool.name,
27
+ description: tool.description,
28
+ parameters: tool.parameters ?? {}
29
+ }
30
+ }))
31
+ });
32
+ }
33
+ export function chatCompletionToResponses(response) {
34
+ const choice = response.choices[0];
35
+ const message = choice?.message;
36
+ const output = [];
37
+ if (message?.content) {
38
+ output.push({
39
+ id: `msg_${response.id}`,
40
+ type: "message",
41
+ status: "completed",
42
+ role: "assistant",
43
+ content: [{ type: "output_text", text: message.content, annotations: [] }]
44
+ });
45
+ }
46
+ for (const toolCall of message?.tool_calls ?? []) {
47
+ output.push({
48
+ id: toolCall.id,
49
+ type: "function_call",
50
+ status: "completed",
51
+ call_id: toolCall.id,
52
+ name: toolCall.function.name,
53
+ arguments: toolCall.function.arguments
54
+ });
55
+ }
56
+ return {
57
+ id: `resp_${response.id}`,
58
+ object: "response",
59
+ status: "completed",
60
+ model: response.model,
61
+ output,
62
+ output_text: outputText(output),
63
+ usage: {
64
+ input_tokens: response.usage?.prompt_tokens ?? 0,
65
+ output_tokens: response.usage?.completion_tokens ?? 0
66
+ }
67
+ };
68
+ }
69
+ function responsesInputItemToChatMessage(item) {
70
+ if (item.type === "function_call_output") {
71
+ return { role: "tool", tool_call_id: item.call_id, content: item.output };
72
+ }
73
+ const content = responsesContentToText(item.content);
74
+ if (item.role === "assistant") {
75
+ return { role: "assistant", content };
76
+ }
77
+ return {
78
+ role: item.role === "user" ? "user" : "system",
79
+ content
80
+ };
81
+ }
82
+ function responsesContentToText(content) {
83
+ if (typeof content === "string") {
84
+ return content;
85
+ }
86
+ return content
87
+ .map((part) => {
88
+ if (part.type === "input_text" || part.type === "output_text" || part.type === "text") {
89
+ return part.text;
90
+ }
91
+ throw new Error(`Unsupported Responses content part type: ${part.type ?? "unknown"}`);
92
+ })
93
+ .join("\n");
94
+ }
95
+ function outputText(output) {
96
+ return output
97
+ .flatMap((item) => (Array.isArray(item.content) ? item.content : []))
98
+ .filter((part) => isRecord(part) && part.type === "output_text" && typeof part.text === "string")
99
+ .map((part) => part.text)
100
+ .join("");
101
+ }
102
+ function isRecord(value) {
103
+ return typeof value === "object" && value !== null;
104
+ }
105
+ function pruneUndefined(value) {
106
+ return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined));
107
+ }
@@ -1,5 +1,6 @@
1
1
  import { createServer } from "node:http";
2
2
  import { anthropicMessageToChatRequest, chatCompletionToAnthropicMessage, createAnthropicStreamEvents, formatSseEvent } from "./anthropic-chat.js";
3
+ import { chatCompletionToResponses, responsesToChatCompletionRequest } from "./responses.js";
3
4
  import { parseOpenAiSseStream } from "./sse.js";
4
5
  export async function startProxyServer(input) {
5
6
  const profile = input.config.profiles[input.profileId];
@@ -30,6 +31,14 @@ export async function startProxyServer(input) {
30
31
  await handleMessages(request, response, profile, fetchImpl);
31
32
  return;
32
33
  }
34
+ if (request.method === "POST" && (request.url === "/v1/chat/completions" || request.url === "/v1/completions")) {
35
+ await passthroughOpenAi(request, response, profile, fetchImpl, request.url);
36
+ return;
37
+ }
38
+ if (request.method === "POST" && request.url === "/v1/responses") {
39
+ await handleResponses(request, response, profile, fetchImpl);
40
+ return;
41
+ }
33
42
  writeJson(response, 404, { error: { type: "not_found_error", message: "Not found" } });
34
43
  }
35
44
  catch (error) {
@@ -45,6 +54,65 @@ export async function startProxyServer(input) {
45
54
  close: () => new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())))
46
55
  };
47
56
  }
57
+ async function passthroughOpenAi(request, response, profile, fetchImpl, path) {
58
+ const body = await readRaw(request);
59
+ const upstream = await fetchImpl(`${profile.baseURL.replace(/\/+$/, "")}${path.replace(/^\/v1/, "")}`, {
60
+ method: "POST",
61
+ headers: {
62
+ "content-type": request.headers["content-type"] ?? "application/json",
63
+ ...(profile.apiKey ? { Authorization: `Bearer ${profile.apiKey}` } : {})
64
+ },
65
+ body: new Uint8Array(body)
66
+ });
67
+ if (!upstream.ok) {
68
+ const message = upstream.text ? await upstream.text() : upstream.statusText;
69
+ writeJson(response, upstream.status ?? 502, { error: { type: "upstream_error", message } });
70
+ return;
71
+ }
72
+ if (isStreamingOpenAiRequest(body) && upstream.body) {
73
+ response.writeHead(200, {
74
+ "content-type": "text/event-stream; charset=utf-8"
75
+ });
76
+ for await (const chunk of upstream.body) {
77
+ response.write(Buffer.from(chunk));
78
+ }
79
+ response.end();
80
+ return;
81
+ }
82
+ if (!upstream.json) {
83
+ throw new Error("Upstream response did not include JSON");
84
+ }
85
+ writeJson(response, 200, await upstream.json());
86
+ }
87
+ function isStreamingOpenAiRequest(body) {
88
+ try {
89
+ return Boolean(JSON.parse(body.toString("utf8")).stream);
90
+ }
91
+ catch {
92
+ return false;
93
+ }
94
+ }
95
+ async function handleResponses(request, response, profile, fetchImpl) {
96
+ const body = (await readJson(request));
97
+ const chatRequest = responsesToChatCompletionRequest(body);
98
+ const upstream = await fetchImpl(`${profile.baseURL.replace(/\/+$/, "")}/chat/completions`, {
99
+ method: "POST",
100
+ headers: {
101
+ "content-type": "application/json",
102
+ ...(profile.apiKey ? { Authorization: `Bearer ${profile.apiKey}` } : {})
103
+ },
104
+ body: JSON.stringify(chatRequest)
105
+ });
106
+ if (!upstream.ok) {
107
+ const message = upstream.text ? await upstream.text() : upstream.statusText;
108
+ writeJson(response, upstream.status ?? 502, { error: { type: "upstream_error", message } });
109
+ return;
110
+ }
111
+ if (!upstream.json) {
112
+ throw new Error("Upstream response did not include JSON");
113
+ }
114
+ writeJson(response, 200, chatCompletionToResponses((await upstream.json())));
115
+ }
48
116
  async function handleMessages(request, response, profile, fetchImpl) {
49
117
  const body = (await readJson(request));
50
118
  const chatRequest = anthropicMessageToChatRequest(body);
@@ -88,11 +156,14 @@ function isAuthorized(request, authToken) {
88
156
  return authorization === `Bearer ${authToken}` || apiKey === authToken;
89
157
  }
90
158
  async function readJson(request) {
159
+ return JSON.parse((await readRaw(request)).toString("utf8") || "{}");
160
+ }
161
+ async function readRaw(request) {
91
162
  const chunks = [];
92
163
  for await (const chunk of request) {
93
164
  chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
94
165
  }
95
- return JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}");
166
+ return Buffer.concat(chunks);
96
167
  }
97
168
  function writeJson(response, status, body) {
98
169
  response.writeHead(status, { "content-type": "application/json; charset=utf-8" });
@@ -0,0 +1,84 @@
1
+ import { copyFile, mkdir, readFile, stat, writeFile } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { dirname, join } from "node:path";
4
+ export function getDefaultCodexConfigPath(homeDir = homedir()) {
5
+ return join(homeDir, ".codex", "config.toml");
6
+ }
7
+ export function mergeCodexConfig(text, provider) {
8
+ let next = text.trimEnd();
9
+ next = upsertTopLevelString(next, "model", provider.model);
10
+ next = upsertTopLevelString(next, "model_provider", provider.providerId);
11
+ next = removeTable(next, `model_providers.${quoteTomlKey(provider.providerId)}`);
12
+ const providerBlock = [
13
+ `[model_providers.${quoteTomlKey(provider.providerId)}]`,
14
+ `name = ${quoteTomlString(provider.providerName)}`,
15
+ `base_url = ${quoteTomlString(provider.baseURL)}`,
16
+ 'wire_api = "responses"',
17
+ "",
18
+ `[model_providers.${quoteTomlKey(provider.providerId)}.auth]`,
19
+ `command = ${quoteTomlString(provider.authCommand)}`,
20
+ `args = [${provider.authArgs.map(quoteTomlString).join(", ")}]`,
21
+ "refresh_interval_ms = 0"
22
+ ].join("\n");
23
+ return `${next.trimEnd()}\n\n${providerBlock}\n`;
24
+ }
25
+ export async function writeCodexConfig(input) {
26
+ const targetPath = input.targetPath ?? getDefaultCodexConfigPath();
27
+ await mkdir(dirname(targetPath), { recursive: true });
28
+ const exists = await fileExists(targetPath);
29
+ const current = exists ? await readFile(targetPath, "utf8") : "";
30
+ let backupPath;
31
+ if (exists) {
32
+ backupPath = `${targetPath}.${timestamp()}.bak`;
33
+ await copyFile(targetPath, backupPath);
34
+ }
35
+ await writeFile(targetPath, mergeCodexConfig(current, input), "utf8");
36
+ return { targetPath, backupPath };
37
+ }
38
+ function upsertTopLevelString(text, key, value) {
39
+ const line = `${key} = ${quoteTomlString(value)}`;
40
+ const pattern = new RegExp(`^${escapeRegExp(key)}\\s*=.*$`, "m");
41
+ if (pattern.test(text)) {
42
+ return text.replace(pattern, line);
43
+ }
44
+ return text ? `${line}\n${text}` : line;
45
+ }
46
+ function removeTable(text, tableName) {
47
+ const lines = text.split(/\r?\n/);
48
+ const header = `[${tableName}]`;
49
+ const kept = [];
50
+ let removing = false;
51
+ for (const line of lines) {
52
+ const trimmed = line.trim();
53
+ if (trimmed.startsWith("[") && trimmed.endsWith("]")) {
54
+ removing = trimmed === header || trimmed.startsWith(`[${tableName}.`);
55
+ if (removing) {
56
+ continue;
57
+ }
58
+ }
59
+ if (!removing) {
60
+ kept.push(line);
61
+ }
62
+ }
63
+ return kept.join("\n").trimEnd();
64
+ }
65
+ function quoteTomlKey(value) {
66
+ return quoteTomlString(value);
67
+ }
68
+ function quoteTomlString(value) {
69
+ return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
70
+ }
71
+ function escapeRegExp(value) {
72
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
73
+ }
74
+ async function fileExists(path) {
75
+ try {
76
+ return (await stat(path)).isFile();
77
+ }
78
+ catch {
79
+ return false;
80
+ }
81
+ }
82
+ function timestamp() {
83
+ return new Date().toISOString().replace(/[:.]/g, "-");
84
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heventure/model-provider-x",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "TUI configurator and local API proxy for wiring custom model providers into OpenCode and Claude Code.",
5
5
  "private": false,
6
6
  "license": "MIT",