@heventure/model-provider-x 0.1.1 → 0.2.1

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
@@ -23,7 +24,7 @@ Or run the published package directly:
23
24
  npx @heventure/model-provider-x --help
24
25
  ```
25
26
 
26
- ## OpenCode Setup
27
+ ## Unified Setup
27
28
 
28
29
  Run the TUI wizard:
29
30
 
@@ -31,7 +32,17 @@ Run the TUI wizard:
31
32
  node dist/cli/index.js
32
33
  ```
33
34
 
34
- Or print a config fragment without writing files:
35
+ The wizard asks for:
36
+
37
+ 1. Provider.
38
+ 2. Models.
39
+ 3. Agent platform: OpenCode, Codex, or Claude Code.
40
+ 4. Direct or proxy mode, with a recommendation based on provider API support.
41
+ 5. Any platform-specific install target.
42
+
43
+ ## OpenCode Setup
44
+
45
+ Print a config fragment without writing files:
35
46
 
36
47
  ```bash
37
48
  node dist/cli/index.js \
@@ -42,6 +53,9 @@ node dist/cli/index.js \
42
53
  --print
43
54
  ```
44
55
 
56
+ OpenCode provider entries include `options.setCacheKey=true` by default.
57
+ This lets OpenCode pass a stable cache key through the OpenAI-compatible provider path so relays or local providers that support prompt caching can route repeated context to the same cache.
58
+
45
59
  ## Claude Code Setup
46
60
 
47
61
  Create or update a provider profile and write Claude Code user settings:
@@ -56,19 +70,68 @@ node dist/cli/index.js setup --target claude-code \
56
70
  Then start the local proxy:
57
71
 
58
72
  ```bash
59
- node dist/cli/index.js proxy --profile unsloth
73
+ node dist/cli/index.js proxy up --profile unsloth
60
74
  ```
61
75
 
62
76
  The Claude Code setup writes gateway environment values to `~/.claude/settings.json`.
77
+ Claude Code appends `/v1/messages` itself, so direct Anthropic-compatible provider URLs are written without a trailing `/v1`.
78
+ For example, entering `http://localhost:1234/v1` for LM Studio writes `ANTHROPIC_BASE_URL=http://localhost:1234`.
79
+ The setup uses `ANTHROPIC_API_KEY` and removes stale `ANTHROPIC_AUTH_TOKEN` values to avoid Claude Code auth conflicts.
63
80
  Upstream provider keys are stored in `~/.config/model-provider-x/config.jsonc`, not in Claude Code settings.
64
81
 
82
+ ## Codex Setup
83
+
84
+ Create or update a provider profile and write Codex user config:
85
+
86
+ ```bash
87
+ node dist/cli/index.js setup --target codex \
88
+ --provider lmstudio
89
+ ```
90
+
91
+ Then start the local proxy:
92
+
93
+ ```bash
94
+ node dist/cli/index.js proxy up --profile lmstudio
95
+ ```
96
+
97
+ The Codex setup writes a Responses-compatible provider to `~/.codex/config.toml`.
98
+ It also configures command-backed authentication so Codex can fetch the local proxy token automatically.
99
+ The proxy currently supports non-streaming `/v1/responses` requests and forwards them to upstream OpenAI-compatible `/v1/chat/completions`.
100
+
101
+ ## Setup Modes
102
+
103
+ You can also run the unified setup wizard through the explicit setup command:
104
+
105
+ ```bash
106
+ node dist/cli/index.js setup --provider lmstudio
107
+ ```
108
+
109
+ The wizard asks whether to write the agent config through the local compatibility proxy before choosing the target agent.
110
+ Proxy mode gives the broadest compatibility:
111
+
112
+ - `/v1/responses` for Codex.
113
+ - `/v1/chat/completions` and `/v1/completions` passthrough for OpenAI-compatible clients.
114
+ - `/v1/messages` for Claude Code.
115
+
116
+ You can force either mode non-interactively:
117
+
118
+ ```bash
119
+ node dist/cli/index.js setup --target codex --provider lmstudio --proxy
120
+ node dist/cli/index.js setup --target opencode --provider lmstudio --direct
121
+ ```
122
+
65
123
  ## Commands
66
124
 
67
125
  ```bash
68
126
  npx @heventure/model-provider-x --help
69
127
  npx @heventure/model-provider-x setup --target claude-code
128
+ npx @heventure/model-provider-x setup --target codex
70
129
  npx @heventure/model-provider-x proxy --profile <id>
130
+ npx @heventure/model-provider-x proxy up --profile <id>
131
+ npx @heventure/model-provider-x proxy status
132
+ npx @heventure/model-provider-x proxy down
71
133
  npx @heventure/model-provider-x config print --profile <id>
134
+ npx @heventure/model-provider-x config api-key --profile <id>
72
135
  ```
73
136
 
74
137
  ## 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;
@@ -86,6 +92,7 @@ export function usage() {
86
92
 
87
93
  Usage:
88
94
  model-provider-x [options]
95
+ model-provider-x setup [options]
89
96
 
90
97
  Options:
91
98
  --base-url <url> OpenAI-compatible API base URL, for example http://localhost:8888/v1
@@ -93,8 +100,10 @@ Options:
93
100
  --name <name> Provider display name.
94
101
  --id <id> Provider id used under provider.<id>.
95
102
  --provider <id> Use a built-in provider preset, for example lmstudio or openai.
103
+ --proxy Write agent config through the local compatibility proxy.
104
+ --direct Write agent config directly to the upstream provider.
96
105
  --models <list> Comma-separated model ids. Skips interactive model selection.
97
- --config <path> OpenCode config path to write.
106
+ --config <path> OpenCode config path to write when targeting OpenCode.
98
107
  --print Print generated JSON and do not write config.
99
108
  --yes, -y Accept defaults in non-interactive prompts.
100
109
  --help, -h Show this help.
@@ -2,7 +2,7 @@ import { HelpRequested, parseCliArgs, usage } from "./args.js";
2
2
  export function parseCommand(argv) {
3
3
  const [command, ...rest] = argv;
4
4
  if (!command || command.startsWith("--")) {
5
- return { command: "opencode", options: parseCliArgs(argv) };
5
+ return { command: "setup", target: undefined, options: parseCliArgs(argv) };
6
6
  }
7
7
  if (command === "setup") {
8
8
  return parseSetupCommand(rest);
@@ -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
- import { getDefaultClaudeSettingsPath, writeClaudeCodeSettings } from "../targets/claude-code.js";
10
+ import { defaultClaudeModelMapping, 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";
@@ -31,6 +34,10 @@ export async function runCommand(command) {
31
34
  return;
32
35
  }
33
36
  if (command.command === "setup") {
37
+ if (command.options.print) {
38
+ await runCli(command.options);
39
+ return;
40
+ }
34
41
  await runSetup(command);
35
42
  return;
36
43
  }
@@ -43,22 +50,16 @@ export async function runCommand(command) {
43
50
  output.write(`${JSON.stringify(profile, null, 2)}\n`);
44
51
  return;
45
52
  }
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
- });
53
+ if (command.command === "config-api-key") {
54
+ const config = await readToolConfig();
55
+ const profile = config.profiles[command.profileId];
56
+ if (!profile?.apiKey) {
57
+ throw new Error(`No API key stored for provider profile: ${command.profileId}`);
58
+ }
59
+ output.write(`${profile.apiKey}\n`);
60
+ return;
61
+ }
62
+ await runProxyCommand(command);
62
63
  }
63
64
  export async function runCli(options) {
64
65
  const rl = createInterface({ input, output });
@@ -107,59 +108,210 @@ export async function runCli(options) {
107
108
  }
108
109
  }
109
110
  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
111
  const rl = createInterface({ input, output });
118
112
  try {
119
113
  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`);
114
+ const providerInput = await collectProviderInput(rl, command);
115
+ output.write("Detecting provider capabilities...\n");
116
+ const capabilities = await detectProviderCapabilities({ baseURL: providerInput.baseURL, apiKey: providerInput.apiKey });
117
+ const selection = await collectProviderSelection(rl, command, providerInput);
118
+ const target = await resolveSetupTarget(rl, command.target);
119
+ const useProxy = await resolveProxyMode(rl, command.options, capabilities, target);
120
+ if (target === "opencode") {
121
+ await writeOpenCodeSetup(rl, command, selection, useProxy);
122
+ return;
123
+ }
124
+ if (target === "codex") {
125
+ await writeCodexSetup(command, selection, useProxy);
126
+ return;
156
127
  }
157
- output.write(`Run proxy: model-provider-x proxy --profile ${providerId}\n`);
128
+ await writeClaudeCodeSetup(rl, command, selection, useProxy);
158
129
  }
159
130
  finally {
160
131
  rl.close();
161
132
  }
162
133
  }
134
+ async function collectProviderInput(rl, command) {
135
+ const providerDefaults = await resolveProviderDefaults(rl, command.options);
136
+ const providerName = await requiredOption(rl, command.options.providerName ?? providerDefaults.name, "Provider name");
137
+ const providerId = await requiredOption(rl, command.profileId ?? command.options.providerId ?? providerDefaults.id ?? slugify(providerName), "Provider id");
138
+ const baseURL = await requiredOption(rl, command.options.baseURL ?? providerDefaults.baseURL, "API base URL");
139
+ const apiKey = await resolveApiKey(rl, command.options.apiKey, providerDefaults.preset, "Upstream API key");
140
+ return { providerId, providerName, baseURL, apiKey };
141
+ }
142
+ async function collectProviderSelection(rl, command, providerInput) {
143
+ output.write("Fetching models...\n");
144
+ const fetched = await validateAndFetchModels({ baseURL: providerInput.baseURL, apiKey: providerInput.apiKey });
145
+ const selectedModels = command.options.models ??
146
+ (canUseTui()
147
+ ? await multiSelectChoices("Select models", createModelChoices(fetched.models))
148
+ : parseModelSelection(await rl.question(formatModelPrompt(fetched.models)), fetched.models));
149
+ const defaultModel = await resolveDefaultModel(rl, command, selectedModels);
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(rl, command, selection, useProxy) {
208
+ const proxyBaseURL = useProxy ? `http://${selection.config.proxy.host}:${selection.config.proxy.port}` : selection.upstreamBaseURL;
209
+ const modelMapping = await resolveClaudeModelMapping(rl, command, selection);
210
+ const result = await writeClaudeCodeSettings({
211
+ targetPath: getDefaultClaudeSettingsPath(),
212
+ proxy: {
213
+ baseURL: proxyBaseURL,
214
+ authToken: useProxy ? selection.config.proxy.authToken : selection.apiKey,
215
+ enableModelDiscovery: useProxy,
216
+ defaultModel: selection.defaultModel,
217
+ models: selection.selectedModels,
218
+ modelMapping
219
+ }
220
+ });
221
+ output.write(`Saved profile ${selection.providerId} to ${selection.toolConfigPath}\n`);
222
+ output.write(`Updated Claude Code settings: ${result.targetPath}\n`);
223
+ if (result.backupPath) {
224
+ output.write(`Backup: ${result.backupPath}\n`);
225
+ }
226
+ if (useProxy) {
227
+ const proxy = await startProxyProcess({
228
+ profileId: selection.providerId,
229
+ config: selection.config,
230
+ entrypoint: fileURLToPath(import.meta.url),
231
+ replace: true
232
+ });
233
+ output.write(`Started proxy: ${proxy.baseURL}\n`);
234
+ }
235
+ }
236
+ async function writeCodexSetup(_command, selection, useProxy) {
237
+ const proxyBaseURL = useProxy
238
+ ? `http://${selection.config.proxy.host}:${selection.config.proxy.port}/v1`
239
+ : selection.upstreamBaseURL;
240
+ const result = await writeCodexConfig({
241
+ targetPath: getDefaultCodexConfigPath(),
242
+ providerId: selection.providerId,
243
+ providerName: selection.providerName,
244
+ baseURL: proxyBaseURL,
245
+ authCommand: "model-provider-x",
246
+ authArgs: useProxy ? ["proxy", "token"] : ["config", "api-key", "--profile", selection.providerId],
247
+ model: selection.defaultModel
248
+ });
249
+ output.write(`Saved profile ${selection.providerId} to ${selection.toolConfigPath}\n`);
250
+ output.write(`Updated Codex config: ${result.targetPath}\n`);
251
+ if (result.backupPath) {
252
+ output.write(`Backup: ${result.backupPath}\n`);
253
+ }
254
+ if (useProxy) {
255
+ const proxy = await startProxyProcess({
256
+ profileId: selection.providerId,
257
+ config: selection.config,
258
+ entrypoint: fileURLToPath(import.meta.url),
259
+ replace: true
260
+ });
261
+ output.write(`Started proxy: ${proxy.baseURL}\n`);
262
+ }
263
+ }
264
+ async function runProxyCommand(command) {
265
+ if (command.action === "up") {
266
+ const config = await readToolConfig();
267
+ const state = await startProxyProcess({
268
+ profileId: command.profileId,
269
+ config,
270
+ host: command.host,
271
+ port: command.port,
272
+ entrypoint: fileURLToPath(import.meta.url)
273
+ });
274
+ output.write(`Proxy running at ${state.baseURL} for profile ${state.profileId}\n`);
275
+ return;
276
+ }
277
+ if (command.action === "down") {
278
+ const status = await stopProxyProcess();
279
+ output.write(status.state ? `Stopped proxy for profile ${status.state.profileId}\n` : "Proxy is not running\n");
280
+ return;
281
+ }
282
+ if (command.action === "status") {
283
+ const status = await readProxyStatus();
284
+ if (!status.state) {
285
+ output.write("Proxy is not running\n");
286
+ return;
287
+ }
288
+ output.write(status.running
289
+ ? `Proxy is running at ${status.state.baseURL} for profile ${status.state.profileId} (pid ${status.state.pid})\n`
290
+ : `Proxy state exists but process ${status.state.pid} is not running\n`);
291
+ return;
292
+ }
293
+ if (command.action === "token") {
294
+ const config = await readToolConfig();
295
+ output.write(`${config.proxy.authToken}\n`);
296
+ return;
297
+ }
298
+ const config = await readToolConfig();
299
+ const server = await startProxyServer({
300
+ profileId: command.profileId,
301
+ config,
302
+ host: command.host,
303
+ port: command.port
304
+ });
305
+ output.write(`model-provider-x proxy listening at ${server.baseURL}\n`);
306
+ await new Promise((resolve) => {
307
+ const stop = async () => {
308
+ await server.close();
309
+ resolve();
310
+ };
311
+ process.once("SIGINT", stop);
312
+ process.once("SIGTERM", stop);
313
+ });
314
+ }
163
315
  async function requiredOption(rl, value, label) {
164
316
  const answer = value ?? (await rl.question(`${label}: `));
165
317
  if (!answer.trim()) {
@@ -186,6 +338,103 @@ async function resolveProviderDefaults(rl, options) {
186
338
  preset
187
339
  };
188
340
  }
341
+ async function resolveProxyMode(rl, options, capabilities, target) {
342
+ if (options.proxy !== undefined) {
343
+ return options.proxy;
344
+ }
345
+ const recommendedProxy = recommendProxyMode(capabilities, target);
346
+ if (canUseTui()) {
347
+ return selectChoice("Connection mode", [
348
+ {
349
+ label: "Direct provider config",
350
+ value: false,
351
+ hint: recommendedProxy ? "target API not detected" : "recommended"
352
+ },
353
+ {
354
+ label: "Use compatibility proxy",
355
+ value: true,
356
+ hint: recommendedProxy ? "recommended" : "maximum compatibility"
357
+ }
358
+ ]);
359
+ }
360
+ const prompt = recommendedProxy ? "Use compatibility proxy? [Y/n] " : "Use direct provider config? [Y/n] ";
361
+ const answer = await rl.question(prompt);
362
+ const accepted = !answer.trim() || answer.trim().toLowerCase() === "y";
363
+ return recommendedProxy ? accepted : !accepted;
364
+ }
365
+ async function resolveDefaultModel(rl, command, selectedModels) {
366
+ if (command.defaultModel) {
367
+ if (!selectedModels.includes(command.defaultModel)) {
368
+ throw new Error(`Default model is not in the selected model list: ${command.defaultModel}`);
369
+ }
370
+ return command.defaultModel;
371
+ }
372
+ if (selectedModels.length === 0) {
373
+ throw new Error("Select at least one model");
374
+ }
375
+ if (selectedModels.length === 1 || command.options.yes) {
376
+ return selectedModels[0];
377
+ }
378
+ if (canUseTui()) {
379
+ return selectChoice("Choose default model", selectedModels.map((model) => ({ label: model, value: model })));
380
+ }
381
+ const answer = await rl.question(`Default model [${selectedModels[0]}]: `);
382
+ const model = answer.trim() || selectedModels[0];
383
+ if (!selectedModels.includes(model)) {
384
+ throw new Error(`Default model is not in the selected model list: ${model}`);
385
+ }
386
+ return model;
387
+ }
388
+ async function resolveClaudeModelMapping(rl, command, selection) {
389
+ const defaultMapping = defaultClaudeModelMapping(selection.defaultModel);
390
+ if (selection.selectedModels.length <= 1 || command.options.yes) {
391
+ return defaultMapping;
392
+ }
393
+ const customize = canUseTui()
394
+ ? await selectChoice("Claude Code model mapping", [
395
+ { label: "Use default model for Opus, Sonnet, Haiku", value: false, hint: "recommended" },
396
+ { label: "Customize Opus, Sonnet, Haiku models", value: true }
397
+ ])
398
+ : (await rl.question("Use default model for Opus, Sonnet, Haiku? [Y/n] ")).trim().toLowerCase() === "n";
399
+ if (!customize) {
400
+ return defaultMapping;
401
+ }
402
+ return {
403
+ opus: await selectModelForRole(rl, "Opus model", selection.selectedModels, selection.defaultModel),
404
+ sonnet: await selectModelForRole(rl, "Sonnet model", selection.selectedModels, selection.defaultModel),
405
+ haiku: await selectModelForRole(rl, "Haiku model", selection.selectedModels, selection.defaultModel),
406
+ subagent: await selectModelForRole(rl, "Subagent model", selection.selectedModels, selection.defaultModel)
407
+ };
408
+ }
409
+ async function selectModelForRole(rl, label, models, defaultModel) {
410
+ if (canUseTui()) {
411
+ return selectChoice(label, models.map((model) => ({ label: model, value: model, hint: model === defaultModel ? "default" : undefined })));
412
+ }
413
+ const answer = await rl.question(`${label} [${defaultModel}]: `);
414
+ const model = answer.trim() || defaultModel;
415
+ if (!models.includes(model)) {
416
+ throw new Error(`${label} is not in the selected model list: ${model}`);
417
+ }
418
+ return model;
419
+ }
420
+ async function resolveSetupTarget(rl, target) {
421
+ if (target) {
422
+ return target;
423
+ }
424
+ if (canUseTui()) {
425
+ return selectChoice("Choose agent platform", [
426
+ { label: "OpenCode", value: "opencode" },
427
+ { label: "Codex", value: "codex" },
428
+ { label: "Claude Code", value: "claude-code" }
429
+ ]);
430
+ }
431
+ const answer = await rl.question("Agent platform [opencode/codex/claude-code]: ");
432
+ const value = answer.trim() || "opencode";
433
+ if (value === "opencode" || value === "codex" || value === "claude-code") {
434
+ return value;
435
+ }
436
+ throw new Error(`Unknown setup target: ${value}`);
437
+ }
189
438
  async function resolveApiKey(rl, apiKey, preset, label = "API key") {
190
439
  if (apiKey !== undefined) {
191
440
  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 && (await isAnthropicMessagesProbe(messages.response))) {
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();
@@ -44,7 +75,8 @@ export function buildProviderConfig(input) {
44
75
  npm: "@ai-sdk/openai-compatible",
45
76
  name: input.providerName.trim(),
46
77
  options: {
47
- baseURL
78
+ baseURL,
79
+ setCacheKey: true
48
80
  },
49
81
  models: Object.fromEntries(input.models.map((model) => [model, { name: model }]))
50
82
  };
@@ -70,3 +102,51 @@ function isOpenCodeCompatibleModel(model) {
70
102
  }
71
103
  return ["llm", "chat", "completion", "text-generation"].includes(model.type.trim().toLowerCase());
72
104
  }
105
+ async function probe(input, init, fetchImpl) {
106
+ try {
107
+ const response = await fetchImpl(input, init);
108
+ return {
109
+ reachable: response.ok || response.status === 400 || response.status === 401 || response.status === 422,
110
+ response
111
+ };
112
+ }
113
+ catch {
114
+ return { reachable: false };
115
+ }
116
+ }
117
+ async function isAnthropicMessagesProbe(response) {
118
+ if (!response) {
119
+ return false;
120
+ }
121
+ const status = response.status ?? (response.ok ? 200 : undefined);
122
+ if (status === 404 || status === undefined) {
123
+ return false;
124
+ }
125
+ if (!response.json) {
126
+ return !response.ok && (status === 400 || status === 401 || status === 422);
127
+ }
128
+ try {
129
+ const body = await response.json();
130
+ if (isAnthropicMessageResponse(body) || isAnthropicErrorResponse(body)) {
131
+ return true;
132
+ }
133
+ return !response.ok && (status === 400 || status === 401 || status === 422);
134
+ }
135
+ catch {
136
+ return !response.ok && (status === 400 || status === 401 || status === 422);
137
+ }
138
+ }
139
+ function isAnthropicMessageResponse(body) {
140
+ return (typeof body === "object" &&
141
+ body !== null &&
142
+ body.type === "message" &&
143
+ typeof body.role === "string" &&
144
+ Array.isArray(body.content));
145
+ }
146
+ function isAnthropicErrorResponse(body) {
147
+ return (typeof body === "object" &&
148
+ body !== null &&
149
+ body.type === "error" &&
150
+ typeof body.error === "object" &&
151
+ body.error !== null);
152
+ }
@@ -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,8 +54,67 @@ export async function startProxyServer(input) {
45
54
  close: () => new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())))
46
55
  };
47
56
  }
48
- async function handleMessages(request, response, profile, fetchImpl) {
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) {
49
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
+ }
116
+ async function handleMessages(request, response, profile, fetchImpl) {
117
+ const body = normalizeAnthropicModel((await readJson(request)), profile);
50
118
  const chatRequest = anthropicMessageToChatRequest(body);
51
119
  const upstream = await fetchImpl(`${profile.baseURL.replace(/\/+$/, "")}/chat/completions`, {
52
120
  method: "POST",
@@ -82,17 +150,32 @@ async function handleMessages(request, response, profile, fetchImpl) {
82
150
  }
83
151
  writeJson(response, 200, chatCompletionToAnthropicMessage((await upstream.json())));
84
152
  }
153
+ function normalizeAnthropicModel(body, profile) {
154
+ if (!isClaudeModelAlias(body.model) || profile.models.includes(body.model) || profile.models.length === 0) {
155
+ return body;
156
+ }
157
+ return {
158
+ ...body,
159
+ model: profile.models[0]
160
+ };
161
+ }
162
+ function isClaudeModelAlias(model) {
163
+ return /(^claude-|sonnet|opus|haiku)/i.test(model);
164
+ }
85
165
  function isAuthorized(request, authToken) {
86
166
  const authorization = request.headers.authorization;
87
167
  const apiKey = request.headers["x-api-key"];
88
168
  return authorization === `Bearer ${authToken}` || apiKey === authToken;
89
169
  }
90
170
  async function readJson(request) {
171
+ return JSON.parse((await readRaw(request)).toString("utf8") || "{}");
172
+ }
173
+ async function readRaw(request) {
91
174
  const chunks = [];
92
175
  for await (const chunk of request) {
93
176
  chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
94
177
  }
95
- return JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}");
178
+ return Buffer.concat(chunks);
96
179
  }
97
180
  function writeJson(response, status, body) {
98
181
  response.writeHead(status, { "content-type": "application/json; charset=utf-8" });
@@ -1,25 +1,85 @@
1
1
  import { copyFile, mkdir, readFile, stat, writeFile } from "node:fs/promises";
2
2
  import { dirname, join } from "node:path";
3
3
  import { homedir } from "node:os";
4
+ const OPUS_MODEL_OVERRIDE_KEYS = [
5
+ "claude-opus-4-7",
6
+ "claude-opus-4-6",
7
+ "claude-opus-4-5",
8
+ "claude-opus-4-1",
9
+ "claude-opus-4-0",
10
+ "claude-3-opus-latest",
11
+ "claude-3-opus-20240229"
12
+ ];
13
+ const SONNET_MODEL_OVERRIDE_KEYS = [
14
+ "claude-sonnet-4-6",
15
+ "claude-sonnet-4-5",
16
+ "claude-sonnet-4-0",
17
+ "claude-3-7-sonnet-latest",
18
+ "claude-3-7-sonnet-20250219",
19
+ "claude-3-5-sonnet-latest",
20
+ "claude-3-5-sonnet-20241022",
21
+ "claude-3-5-sonnet-20240620"
22
+ ];
23
+ const HAIKU_MODEL_OVERRIDE_KEYS = [
24
+ "claude-haiku-4-5",
25
+ "claude-3-5-haiku-latest",
26
+ "claude-3-5-haiku-20241022",
27
+ "claude-3-haiku-20240307"
28
+ ];
4
29
  export function getDefaultClaudeSettingsPath(homeDir = homedir()) {
5
30
  return join(homeDir, ".claude", "settings.json");
6
31
  }
7
32
  export function mergeClaudeCodeSettings(settings, proxy) {
8
33
  const env = {
9
34
  ...(isRecord(settings.env) ? stringifyRecord(settings.env) : {}),
10
- ANTHROPIC_BASE_URL: proxy.baseURL,
11
- ANTHROPIC_AUTH_TOKEN: proxy.authToken
35
+ ANTHROPIC_BASE_URL: normalizeClaudeCodeBaseURL(proxy.baseURL),
36
+ ANTHROPIC_API_KEY: proxy.authToken
12
37
  };
38
+ delete env.ANTHROPIC_AUTH_TOKEN;
13
39
  if (proxy.enableModelDiscovery) {
14
40
  env.CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY = "1";
15
41
  }
16
42
  if (proxy.defaultModel) {
43
+ const mapping = proxy.modelMapping ?? defaultClaudeModelMapping(proxy.defaultModel);
17
44
  env.ANTHROPIC_MODEL = proxy.defaultModel;
45
+ env.ANTHROPIC_DEFAULT_OPUS_MODEL = mapping.opus;
46
+ env.ANTHROPIC_DEFAULT_SONNET_MODEL = mapping.sonnet;
47
+ env.ANTHROPIC_DEFAULT_HAIKU_MODEL = mapping.haiku;
48
+ env.CLAUDE_CODE_SUBAGENT_MODEL = mapping.subagent;
18
49
  }
19
- return {
50
+ const next = {
20
51
  ...settings,
21
52
  env
22
53
  };
54
+ if (proxy.defaultModel) {
55
+ const defaultModel = proxy.defaultModel;
56
+ const mapping = proxy.modelMapping ?? defaultClaudeModelMapping(defaultModel);
57
+ next.model = defaultModel;
58
+ next.modelOverrides = {
59
+ ...(isRecord(settings.modelOverrides) ? stringifyRecord(settings.modelOverrides) : {}),
60
+ ...modelOverridesForMapping(mapping)
61
+ };
62
+ }
63
+ const availableModels = uniqueStrings([
64
+ ...(Array.isArray(settings.availableModels) ? settings.availableModels.map(String) : []),
65
+ ...(proxy.models ?? []),
66
+ ...(proxy.defaultModel ? [proxy.defaultModel] : [])
67
+ ]);
68
+ if (availableModels.length > 0) {
69
+ next.availableModels = availableModels;
70
+ }
71
+ return next;
72
+ }
73
+ export function defaultClaudeModelMapping(defaultModel) {
74
+ return {
75
+ opus: defaultModel,
76
+ sonnet: defaultModel,
77
+ haiku: defaultModel,
78
+ subagent: defaultModel
79
+ };
80
+ }
81
+ export function normalizeClaudeCodeBaseURL(baseURL) {
82
+ return baseURL.trim().replace(/\/+$/, "").replace(/\/v1$/i, "");
23
83
  }
24
84
  export async function writeClaudeCodeSettings(input) {
25
85
  const targetPath = input.targetPath ?? getDefaultClaudeSettingsPath();
@@ -46,6 +106,16 @@ async function fileExists(path) {
46
106
  function stringifyRecord(record) {
47
107
  return Object.fromEntries(Object.entries(record).map(([key, value]) => [key, String(value)]));
48
108
  }
109
+ function uniqueStrings(values) {
110
+ return [...new Set(values.map((value) => value.trim()).filter(Boolean))];
111
+ }
112
+ function modelOverridesForMapping(mapping) {
113
+ return {
114
+ ...Object.fromEntries(OPUS_MODEL_OVERRIDE_KEYS.map((model) => [model, mapping.opus])),
115
+ ...Object.fromEntries(SONNET_MODEL_OVERRIDE_KEYS.map((model) => [model, mapping.sonnet])),
116
+ ...Object.fromEntries(HAIKU_MODEL_OVERRIDE_KEYS.map((model) => [model, mapping.haiku]))
117
+ };
118
+ }
49
119
  function isRecord(value) {
50
120
  return typeof value === "object" && value !== null && !Array.isArray(value);
51
121
  }
@@ -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.1",
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",