@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 +66 -3
- package/dist/cli/args.js +10 -1
- package/dist/cli/commands.js +21 -11
- package/dist/cli/index.js +311 -62
- package/dist/core/provider.js +81 -1
- package/dist/core/proxy-process.js +102 -0
- package/dist/proxy/responses.js +107 -0
- package/dist/proxy/server.js +85 -2
- package/dist/targets/claude-code.js +73 -3
- 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
|
|
@@ -23,7 +24,7 @@ Or run the published package directly:
|
|
|
23
24
|
npx @heventure/model-provider-x --help
|
|
24
25
|
```
|
|
25
26
|
|
|
26
|
-
##
|
|
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
|
-
|
|
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.
|
package/dist/cli/commands.js
CHANGED
|
@@ -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: "
|
|
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
|
|
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,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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
|
121
|
-
|
|
122
|
-
const
|
|
123
|
-
const
|
|
124
|
-
const
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
|
|
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;
|
package/dist/core/provider.js
CHANGED
|
@@ -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
|
+
}
|
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,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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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