@heventure/model-provider-x 0.2.0 → 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
@@ -24,7 +24,7 @@ Or run the published package directly:
24
24
  npx @heventure/model-provider-x --help
25
25
  ```
26
26
 
27
- ## OpenCode Setup
27
+ ## Unified Setup
28
28
 
29
29
  Run the TUI wizard:
30
30
 
@@ -32,7 +32,17 @@ Run the TUI wizard:
32
32
  node dist/cli/index.js
33
33
  ```
34
34
 
35
- 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:
36
46
 
37
47
  ```bash
38
48
  node dist/cli/index.js \
@@ -43,6 +53,9 @@ node dist/cli/index.js \
43
53
  --print
44
54
  ```
45
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
+
46
59
  ## Claude Code Setup
47
60
 
48
61
  Create or update a provider profile and write Claude Code user settings:
@@ -61,6 +74,9 @@ node dist/cli/index.js proxy up --profile unsloth
61
74
  ```
62
75
 
63
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.
64
80
  Upstream provider keys are stored in `~/.config/model-provider-x/config.jsonc`, not in Claude Code settings.
65
81
 
66
82
  ## Codex Setup
@@ -82,9 +98,9 @@ The Codex setup writes a Responses-compatible provider to `~/.codex/config.toml`
82
98
  It also configures command-backed authentication so Codex can fetch the local proxy token automatically.
83
99
  The proxy currently supports non-streaming `/v1/responses` requests and forwards them to upstream OpenAI-compatible `/v1/chat/completions`.
84
100
 
85
- ## Unified Setup Flow
101
+ ## Setup Modes
86
102
 
87
- Use the unified setup wizard to install a provider into OpenCode, Codex, or Claude Code:
103
+ You can also run the unified setup wizard through the explicit setup command:
88
104
 
89
105
  ```bash
90
106
  node dist/cli/index.js setup --provider lmstudio
package/dist/cli/args.js CHANGED
@@ -92,6 +92,7 @@ export function usage() {
92
92
 
93
93
  Usage:
94
94
  model-provider-x [options]
95
+ model-provider-x setup [options]
95
96
 
96
97
  Options:
97
98
  --base-url <url> OpenAI-compatible API base URL, for example http://localhost:8888/v1
@@ -102,7 +103,7 @@ Options:
102
103
  --proxy Write agent config through the local compatibility proxy.
103
104
  --direct Write agent config directly to the upstream provider.
104
105
  --models <list> Comma-separated model ids. Skips interactive model selection.
105
- --config <path> OpenCode config path to write.
106
+ --config <path> OpenCode config path to write when targeting OpenCode.
106
107
  --print Print generated JSON and do not write config.
107
108
  --yes, -y Accept defaults in non-interactive prompts.
108
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);
package/dist/cli/index.js CHANGED
@@ -7,7 +7,7 @@ import { getDefaultToolConfigPath, readToolConfig, upsertProviderProfile } from
7
7
  import { discoverOpenCodeConfigs, getDefaultConfigPath, writeProviderToConfig } from "../core/config.js";
8
8
  import { buildProviderConfig, detectProviderCapabilities, recommendProxyMode, validateAndFetchModels } from "../core/provider.js";
9
9
  import { startProxyServer } from "../proxy/server.js";
10
- import { getDefaultClaudeSettingsPath, writeClaudeCodeSettings } from "../targets/claude-code.js";
10
+ import { defaultClaudeModelMapping, getDefaultClaudeSettingsPath, writeClaudeCodeSettings } from "../targets/claude-code.js";
11
11
  import { getDefaultCodexConfigPath, writeCodexConfig } from "../targets/codex.js";
12
12
  import { HelpRequested, parseModelSelection } from "./args.js";
13
13
  import { commandUsage, parseCommand } from "./commands.js";
@@ -34,6 +34,10 @@ export async function runCommand(command) {
34
34
  return;
35
35
  }
36
36
  if (command.command === "setup") {
37
+ if (command.options.print) {
38
+ await runCli(command.options);
39
+ return;
40
+ }
37
41
  await runSetup(command);
38
42
  return;
39
43
  }
@@ -110,9 +114,9 @@ async function runSetup(command) {
110
114
  const providerInput = await collectProviderInput(rl, command);
111
115
  output.write("Detecting provider capabilities...\n");
112
116
  const capabilities = await detectProviderCapabilities({ baseURL: providerInput.baseURL, apiKey: providerInput.apiKey });
117
+ const selection = await collectProviderSelection(rl, command, providerInput);
113
118
  const target = await resolveSetupTarget(rl, command.target);
114
119
  const useProxy = await resolveProxyMode(rl, command.options, capabilities, target);
115
- const selection = await collectProviderSelection(rl, command, target, providerInput);
116
120
  if (target === "opencode") {
117
121
  await writeOpenCodeSetup(rl, command, selection, useProxy);
118
122
  return;
@@ -121,7 +125,7 @@ async function runSetup(command) {
121
125
  await writeCodexSetup(command, selection, useProxy);
122
126
  return;
123
127
  }
124
- await writeClaudeCodeSetup(command, selection, useProxy);
128
+ await writeClaudeCodeSetup(rl, command, selection, useProxy);
125
129
  }
126
130
  finally {
127
131
  rl.close();
@@ -135,18 +139,14 @@ async function collectProviderInput(rl, command) {
135
139
  const apiKey = await resolveApiKey(rl, command.options.apiKey, providerDefaults.preset, "Upstream API key");
136
140
  return { providerId, providerName, baseURL, apiKey };
137
141
  }
138
- async function collectProviderSelection(rl, command, target, providerInput) {
142
+ async function collectProviderSelection(rl, command, providerInput) {
139
143
  output.write("Fetching models...\n");
140
144
  const fetched = await validateAndFetchModels({ baseURL: providerInput.baseURL, apiKey: providerInput.apiKey });
141
145
  const selectedModels = command.options.models ??
142
146
  (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))
147
+ ? await multiSelectChoices("Select models", createModelChoices(fetched.models))
148
148
  : parseModelSelection(await rl.question(formatModelPrompt(fetched.models)), fetched.models));
149
- const defaultModel = command.defaultModel ?? selectedModels[0];
149
+ const defaultModel = await resolveDefaultModel(rl, command, selectedModels);
150
150
  if (!defaultModel) {
151
151
  throw new Error("Select at least one model");
152
152
  }
@@ -204,15 +204,18 @@ async function writeOpenCodeSetup(rl, command, selection, useProxy) {
204
204
  output.write(`Started proxy: ${proxy.baseURL}\n`);
205
205
  }
206
206
  }
207
- async function writeClaudeCodeSetup(command, selection, useProxy) {
207
+ async function writeClaudeCodeSetup(rl, command, selection, useProxy) {
208
208
  const proxyBaseURL = useProxy ? `http://${selection.config.proxy.host}:${selection.config.proxy.port}` : selection.upstreamBaseURL;
209
+ const modelMapping = await resolveClaudeModelMapping(rl, command, selection);
209
210
  const result = await writeClaudeCodeSettings({
210
211
  targetPath: getDefaultClaudeSettingsPath(),
211
212
  proxy: {
212
213
  baseURL: proxyBaseURL,
213
214
  authToken: useProxy ? selection.config.proxy.authToken : selection.apiKey,
214
215
  enableModelDiscovery: useProxy,
215
- defaultModel: command.defaultModel
216
+ defaultModel: selection.defaultModel,
217
+ models: selection.selectedModels,
218
+ modelMapping
216
219
  }
217
220
  });
218
221
  output.write(`Saved profile ${selection.providerId} to ${selection.toolConfigPath}\n`);
@@ -359,6 +362,61 @@ async function resolveProxyMode(rl, options, capabilities, target) {
359
362
  const accepted = !answer.trim() || answer.trim().toLowerCase() === "y";
360
363
  return recommendedProxy ? accepted : !accepted;
361
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
+ }
362
420
  async function resolveSetupTarget(rl, target) {
363
421
  if (target) {
364
422
  return target;
@@ -51,7 +51,7 @@ export async function detectProviderCapabilities(input, fetchImpl = globalThis.f
51
51
  apis.add("openai-responses");
52
52
  }
53
53
  const messages = await probe(`${baseURL}/messages`, { method: "POST", headers: { ...headers, "content-type": "application/json" }, body: "{}" }, fetchImpl);
54
- if (messages.reachable) {
54
+ if (messages.reachable && (await isAnthropicMessagesProbe(messages.response))) {
55
55
  apis.add("anthropic-messages");
56
56
  }
57
57
  return { baseURL, apis: [...apis] };
@@ -75,7 +75,8 @@ export function buildProviderConfig(input) {
75
75
  npm: "@ai-sdk/openai-compatible",
76
76
  name: input.providerName.trim(),
77
77
  options: {
78
- baseURL
78
+ baseURL,
79
+ setCacheKey: true
79
80
  },
80
81
  models: Object.fromEntries(input.models.map((model) => [model, { name: model }]))
81
82
  };
@@ -104,9 +105,48 @@ function isOpenCodeCompatibleModel(model) {
104
105
  async function probe(input, init, fetchImpl) {
105
106
  try {
106
107
  const response = await fetchImpl(input, init);
107
- return { reachable: response.ok || response.status === 400 || response.status === 401 || response.status === 422 };
108
+ return {
109
+ reachable: response.ok || response.status === 400 || response.status === 401 || response.status === 422,
110
+ response
111
+ };
108
112
  }
109
113
  catch {
110
114
  return { reachable: false };
111
115
  }
112
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
+ }
@@ -114,7 +114,7 @@ async function handleResponses(request, response, profile, fetchImpl) {
114
114
  writeJson(response, 200, chatCompletionToResponses((await upstream.json())));
115
115
  }
116
116
  async function handleMessages(request, response, profile, fetchImpl) {
117
- const body = (await readJson(request));
117
+ const body = normalizeAnthropicModel((await readJson(request)), profile);
118
118
  const chatRequest = anthropicMessageToChatRequest(body);
119
119
  const upstream = await fetchImpl(`${profile.baseURL.replace(/\/+$/, "")}/chat/completions`, {
120
120
  method: "POST",
@@ -150,6 +150,18 @@ async function handleMessages(request, response, profile, fetchImpl) {
150
150
  }
151
151
  writeJson(response, 200, chatCompletionToAnthropicMessage((await upstream.json())));
152
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
+ }
153
165
  function isAuthorized(request, authToken) {
154
166
  const authorization = request.headers.authorization;
155
167
  const apiKey = request.headers["x-api-key"];
@@ -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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heventure/model-provider-x",
3
- "version": "0.2.0",
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",