@heventure/model-provider-x 0.1.0 → 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 +48 -1
- package/dist/cli/args.js +12 -0
- package/dist/cli/commands.js +20 -10
- package/dist/cli/index.js +286 -65
- package/dist/cli/model-choices.js +17 -0
- package/dist/cli/provider-presets.js +24 -0
- package/dist/cli/tui.js +3 -1
- package/dist/core/provider.js +53 -2
- package/dist/core/proxy-process.js +102 -0
- package/dist/proxy/responses.js +107 -0
- package/dist/proxy/server.js +72 -1
- package/dist/targets/codex.js +84 -0
- package/package.json +1 -1
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
|
@@ -32,6 +32,15 @@ export function parseCliArgs(argv) {
|
|
|
32
32
|
case "--name":
|
|
33
33
|
options.providerName = next();
|
|
34
34
|
break;
|
|
35
|
+
case "--provider":
|
|
36
|
+
options.providerPreset = next();
|
|
37
|
+
break;
|
|
38
|
+
case "--proxy":
|
|
39
|
+
options.proxy = true;
|
|
40
|
+
break;
|
|
41
|
+
case "--direct":
|
|
42
|
+
options.proxy = false;
|
|
43
|
+
break;
|
|
35
44
|
case "--print":
|
|
36
45
|
options.print = true;
|
|
37
46
|
break;
|
|
@@ -89,6 +98,9 @@ Options:
|
|
|
89
98
|
--api-key <key> Optional API key. Written into config when provided.
|
|
90
99
|
--name <name> Provider display name.
|
|
91
100
|
--id <id> Provider id used under provider.<id>.
|
|
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.
|
|
92
104
|
--models <list> Comma-separated model ids. Skips interactive model selection.
|
|
93
105
|
--config <path> OpenCode config path to write.
|
|
94
106
|
--print Print generated JSON and do not write config.
|
package/dist/cli/commands.js
CHANGED
|
@@ -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
|
|
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 <
|
|
73
|
-
const arg =
|
|
82
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
83
|
+
const arg = args[index];
|
|
74
84
|
const next = () => {
|
|
75
|
-
const value =
|
|
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,13 +1,18 @@
|
|
|
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";
|
|
14
|
+
import { createModelChoices } from "./model-choices.js";
|
|
15
|
+
import { createProviderChoices, getProviderPreset } from "./provider-presets.js";
|
|
11
16
|
import { canUseTui, multiSelectChoices, renderIntro, selectChoice } from "./tui.js";
|
|
12
17
|
async function main() {
|
|
13
18
|
try {
|
|
@@ -41,36 +46,31 @@ export async function runCommand(command) {
|
|
|
41
46
|
output.write(`${JSON.stringify(profile, null, 2)}\n`);
|
|
42
47
|
return;
|
|
43
48
|
}
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
await server.close();
|
|
55
|
-
resolve();
|
|
56
|
-
};
|
|
57
|
-
process.once("SIGINT", stop);
|
|
58
|
-
process.once("SIGTERM", stop);
|
|
59
|
-
});
|
|
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);
|
|
60
59
|
}
|
|
61
60
|
export async function runCli(options) {
|
|
62
61
|
const rl = createInterface({ input, output });
|
|
63
62
|
try {
|
|
64
63
|
output.write(canUseTui() ? renderIntro() : "model-provider-x\n\n");
|
|
65
|
-
const
|
|
66
|
-
const
|
|
67
|
-
const
|
|
68
|
-
const
|
|
64
|
+
const providerDefaults = await resolveProviderDefaults(rl, options);
|
|
65
|
+
const providerName = await requiredOption(rl, options.providerName ?? providerDefaults.name, "Provider name");
|
|
66
|
+
const providerId = await requiredOption(rl, options.providerId ?? providerDefaults.id ?? slugify(providerName), "Provider id");
|
|
67
|
+
const baseURL = await requiredOption(rl, options.baseURL ?? providerDefaults.baseURL, "API base URL");
|
|
68
|
+
const apiKey = await resolveApiKey(rl, options.apiKey, providerDefaults.preset);
|
|
69
69
|
output.write("Fetching models...\n");
|
|
70
70
|
const fetched = await validateAndFetchModels({ baseURL, apiKey });
|
|
71
71
|
const selectedModels = options.models ??
|
|
72
72
|
(canUseTui()
|
|
73
|
-
? await multiSelectChoices("Select models", fetched.models
|
|
73
|
+
? await multiSelectChoices("Select models", createModelChoices(fetched.models))
|
|
74
74
|
: parseModelSelection(await rl.question(formatModelPrompt(fetched.models)), fetched.models));
|
|
75
75
|
const fragment = buildProviderConfig({
|
|
76
76
|
providerId,
|
|
@@ -104,58 +104,211 @@ export async function runCli(options) {
|
|
|
104
104
|
}
|
|
105
105
|
}
|
|
106
106
|
async function runSetup(command) {
|
|
107
|
-
if (command.target === "opencode") {
|
|
108
|
-
await runCli(command.options);
|
|
109
|
-
return;
|
|
110
|
-
}
|
|
111
|
-
await runClaudeCodeSetup(command);
|
|
112
|
-
}
|
|
113
|
-
async function runClaudeCodeSetup(command) {
|
|
114
107
|
const rl = createInterface({ input, output });
|
|
115
108
|
try {
|
|
116
109
|
output.write(canUseTui() ? renderIntro() : "model-provider-x\n\n");
|
|
117
|
-
const
|
|
118
|
-
|
|
119
|
-
const
|
|
120
|
-
const
|
|
121
|
-
|
|
122
|
-
const
|
|
123
|
-
|
|
124
|
-
(
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
name: providerName,
|
|
131
|
-
baseURL: fetched.baseURL,
|
|
132
|
-
apiKey: apiKey.trim() || undefined,
|
|
133
|
-
models: selectedModels
|
|
134
|
-
}, {
|
|
135
|
-
host: command.host,
|
|
136
|
-
port: command.port
|
|
137
|
-
});
|
|
138
|
-
const proxyBaseURL = `http://${config.proxy.host}:${config.proxy.port}`;
|
|
139
|
-
const result = await writeClaudeCodeSettings({
|
|
140
|
-
targetPath: getDefaultClaudeSettingsPath(),
|
|
141
|
-
proxy: {
|
|
142
|
-
baseURL: proxyBaseURL,
|
|
143
|
-
authToken: config.proxy.authToken,
|
|
144
|
-
enableModelDiscovery: true,
|
|
145
|
-
defaultModel: command.defaultModel
|
|
146
|
-
}
|
|
147
|
-
});
|
|
148
|
-
output.write(`Saved profile ${providerId} to ${toolConfigPath}\n`);
|
|
149
|
-
output.write(`Updated Claude Code settings: ${result.targetPath}\n`);
|
|
150
|
-
if (result.backupPath) {
|
|
151
|
-
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;
|
|
119
|
+
}
|
|
120
|
+
if (target === "codex") {
|
|
121
|
+
await writeCodexSetup(command, selection, useProxy);
|
|
122
|
+
return;
|
|
152
123
|
}
|
|
153
|
-
|
|
124
|
+
await writeClaudeCodeSetup(command, selection, useProxy);
|
|
154
125
|
}
|
|
155
126
|
finally {
|
|
156
127
|
rl.close();
|
|
157
128
|
}
|
|
158
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
|
+
}
|
|
159
312
|
async function requiredOption(rl, value, label) {
|
|
160
313
|
const answer = value ?? (await rl.question(`${label}: `));
|
|
161
314
|
if (!answer.trim()) {
|
|
@@ -163,6 +316,74 @@ async function requiredOption(rl, value, label) {
|
|
|
163
316
|
}
|
|
164
317
|
return answer.trim();
|
|
165
318
|
}
|
|
319
|
+
async function resolveProviderDefaults(rl, options) {
|
|
320
|
+
if (options.baseURL) {
|
|
321
|
+
return {};
|
|
322
|
+
}
|
|
323
|
+
const presetId = options.providerPreset ?? (canUseTui() ? await selectChoice("Choose provider", createProviderChoices()) : undefined);
|
|
324
|
+
if (!presetId || presetId === "custom") {
|
|
325
|
+
return {};
|
|
326
|
+
}
|
|
327
|
+
const preset = getProviderPreset(presetId);
|
|
328
|
+
if (!preset) {
|
|
329
|
+
throw new Error(`Unknown provider preset: ${presetId}`);
|
|
330
|
+
}
|
|
331
|
+
return {
|
|
332
|
+
id: preset.id,
|
|
333
|
+
name: preset.name,
|
|
334
|
+
baseURL: preset.baseURL,
|
|
335
|
+
preset
|
|
336
|
+
};
|
|
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
|
+
}
|
|
380
|
+
async function resolveApiKey(rl, apiKey, preset, label = "API key") {
|
|
381
|
+
if (apiKey !== undefined) {
|
|
382
|
+
return apiKey;
|
|
383
|
+
}
|
|
384
|
+
const suffix = preset?.apiKeyRequired ? "" : " (optional)";
|
|
385
|
+
return rl.question(`${label}${suffix}: `);
|
|
386
|
+
}
|
|
166
387
|
async function chooseConfigPath(rl, providerId, yes) {
|
|
167
388
|
const configs = await discoverOpenCodeConfigs({ providerId });
|
|
168
389
|
if (configs.length === 0) {
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
const likelyUnsupportedModelName = /\b(?:embed|embedding|embeddings|bge|e5|nomic-embed)\b/i;
|
|
2
|
+
export function createModelChoices(models) {
|
|
3
|
+
return models.map((model) => {
|
|
4
|
+
if (!isLikelyUnsupportedModelName(model)) {
|
|
5
|
+
return { label: model, value: model, selected: true };
|
|
6
|
+
}
|
|
7
|
+
return {
|
|
8
|
+
label: model,
|
|
9
|
+
value: model,
|
|
10
|
+
hint: "suspected unsupported model",
|
|
11
|
+
selected: false
|
|
12
|
+
};
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
export function isLikelyUnsupportedModelName(model) {
|
|
16
|
+
return likelyUnsupportedModelName.test(model);
|
|
17
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
const providerPresets = [
|
|
2
|
+
{ id: "unsloth", name: "Unsloth Local", baseURL: "http://localhost:8888/v1", apiKeyRequired: false },
|
|
3
|
+
{ id: "lmstudio", name: "LM Studio", baseURL: "http://localhost:1234/v1", apiKeyRequired: false },
|
|
4
|
+
{ id: "ollama", name: "Ollama", baseURL: "http://localhost:11434/v1", apiKeyRequired: false },
|
|
5
|
+
{ id: "vllm", name: "vLLM", baseURL: "http://localhost:8000/v1", apiKeyRequired: false },
|
|
6
|
+
{ id: "openai", name: "OpenAI", baseURL: "https://api.openai.com/v1", apiKeyRequired: true },
|
|
7
|
+
{ id: "openrouter", name: "OpenRouter", baseURL: "https://openrouter.ai/api/v1", apiKeyRequired: true }
|
|
8
|
+
];
|
|
9
|
+
export function getProviderPresets() {
|
|
10
|
+
return [...providerPresets];
|
|
11
|
+
}
|
|
12
|
+
export function getProviderPreset(id) {
|
|
13
|
+
return providerPresets.find((preset) => preset.id === id);
|
|
14
|
+
}
|
|
15
|
+
export function createProviderChoices() {
|
|
16
|
+
return [
|
|
17
|
+
...providerPresets.map((preset) => ({
|
|
18
|
+
label: preset.name,
|
|
19
|
+
value: preset.id,
|
|
20
|
+
hint: preset.baseURL
|
|
21
|
+
})),
|
|
22
|
+
{ label: "Custom provider", value: "custom", hint: "enter URL and API key manually" }
|
|
23
|
+
];
|
|
24
|
+
}
|
package/dist/cli/tui.js
CHANGED
|
@@ -45,7 +45,9 @@ export async function selectChoice(title, choices, streams = { input: process.st
|
|
|
45
45
|
return selected.value;
|
|
46
46
|
}
|
|
47
47
|
export async function multiSelectChoices(title, choices, streams = { input: process.stdin, output: process.stdout }) {
|
|
48
|
-
let selected = new Set(choices
|
|
48
|
+
let selected = new Set(choices
|
|
49
|
+
.map((choice, index) => (choice.disabled || choice.selected === false ? -1 : index))
|
|
50
|
+
.filter((index) => index >= 0));
|
|
49
51
|
const choice = await runKeyMenu((cursor) => renderMultiSelect(title, choices, cursor, selected), choices, streams, (key, cursor) => {
|
|
50
52
|
if (key.name === "space") {
|
|
51
53
|
selected = toggleSelectedIndex(selected, cursor);
|
package/dist/core/provider.js
CHANGED
|
@@ -26,12 +26,48 @@ export async function validateAndFetchModels(input, fetchImpl = globalThis.fetch
|
|
|
26
26
|
if (!isModelListResponse(body)) {
|
|
27
27
|
throw new Error("Expected /models to return an object with a data array");
|
|
28
28
|
}
|
|
29
|
-
const models = [
|
|
29
|
+
const models = [
|
|
30
|
+
...new Set(body.data
|
|
31
|
+
.filter(isOpenCodeCompatibleModel)
|
|
32
|
+
.map((model) => model.id.trim())
|
|
33
|
+
.filter(Boolean))
|
|
34
|
+
];
|
|
30
35
|
if (models.length === 0) {
|
|
31
|
-
throw new Error("Provider returned no model ids");
|
|
36
|
+
throw new Error("Provider returned no OpenCode-compatible model ids");
|
|
32
37
|
}
|
|
33
38
|
return { baseURL, models };
|
|
34
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
|
+
}
|
|
35
71
|
export function buildProviderConfig(input) {
|
|
36
72
|
const baseURL = normalizeBaseUrl(input.baseURL);
|
|
37
73
|
const apiKey = input.apiKey?.trim();
|
|
@@ -59,3 +95,18 @@ function isModelListResponse(body) {
|
|
|
59
95
|
Array.isArray(body.data) &&
|
|
60
96
|
body.data.every((model) => typeof model === "object" && model !== null && typeof model.id === "string"));
|
|
61
97
|
}
|
|
98
|
+
function isOpenCodeCompatibleModel(model) {
|
|
99
|
+
if (typeof model.type !== "string") {
|
|
100
|
+
return true;
|
|
101
|
+
}
|
|
102
|
+
return ["llm", "chat", "completion", "text-generation"].includes(model.type.trim().toLowerCase());
|
|
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
|
+
}
|
package/dist/proxy/server.js
CHANGED
|
@@ -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
|
|
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