@ouro.bot/cli 0.1.0-alpha.955 → 0.1.0-alpha.956
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/changelog.json +8 -0
- package/deploy/unraid/credit-gateway.md +95 -0
- package/deploy/unraid/sanctuary.ouro/bundle-meta.json +1 -1
- package/deploy/unraid/sanctuary.xml +1 -1
- package/dist/gateway/keys.js +117 -0
- package/dist/gateway/server.js +429 -0
- package/dist/heart/auth/auth-flow.js +17 -3
- package/dist/heart/config.js +5 -1
- package/dist/heart/daemon/cli-exec.js +25 -0
- package/dist/heart/daemon/cli-help.js +14 -2
- package/dist/heart/daemon/cli-parse.js +83 -0
- package/dist/heart/identity.js +1 -1
- package/dist/heart/provider-credentials.js +2 -2
- package/dist/heart/provider-ping.js +2 -1
- package/dist/heart/providers/anthropic.js +65 -7
- package/dist/heart/runtime-credentials.js +2 -1
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
package/changelog.json
CHANGED
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"_note": "This changelog is maintained as part of the PR/version-bump workflow. Agent-curated, not auto-generated. Agents read this file directly via read_file to understand what changed between versions.",
|
|
3
3
|
"versions": [
|
|
4
|
+
{
|
|
5
|
+
"version": "0.1.0-alpha.956",
|
|
6
|
+
"changes": [
|
|
7
|
+
"A new credit gateway (ouro gateway serve) lets agents use one master key while it spends several Anthropic API keys in a fixed order, moving to the next key only when one runs out of credits, with a health page and a runbook for running it next to the Butler on Unraid.",
|
|
8
|
+
"The Anthropic provider can now use a plain API key and a custom base URL (ouro auth --provider anthropic --api-key-file <path> --base-url <url>, which reads the key from a file or standard input and never from the command line), so an agent can run on API credits through the credit gateway; the existing setup-token login works as before.",
|
|
9
|
+
"The credit gateway refuses any request path that would send a key to a host other than the Anthropic API."
|
|
10
|
+
]
|
|
11
|
+
},
|
|
4
12
|
{
|
|
5
13
|
"version": "0.1.0-alpha.955",
|
|
6
14
|
"changes": [
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# Credit gateway runbook
|
|
2
|
+
|
|
3
|
+
The credit gateway lets the Butler run on the API credits of several Claude Max plans through one key. Agents present a single master key; the gateway holds the real Anthropic API keys and spends them in a fixed order: it uses the first key until the API says that key's credit balance is too low, then the second, then the third. It never spreads load across keys.
|
|
4
|
+
|
|
5
|
+
It runs from the same image as the Butler (no separate image), as a second container named `ouro-credit-gateway`. Both containers use host networking, so the gateway listens on `127.0.0.1:8787` and the Butler reaches it at `http://127.0.0.1:8787`. Nothing is published to the network.
|
|
6
|
+
|
|
7
|
+
## How it behaves
|
|
8
|
+
|
|
9
|
+
- `master.key` in the keys folder is the key clients must send, as `x-api-key` or `Authorization: Bearer`. A wrong or missing key gets HTTP 401 `invalid x-api-key`.
|
|
10
|
+
- Every other `*.key` file is an upstream Anthropic API key. The gateway uses them in file-name order (`acct1.key`, `acct2.key`, `acct3.key`). The label in logs and health is the file name without `.key`. Key contents are never logged.
|
|
11
|
+
- The gateway reads the keys once at start. Restart the container after changing a key file.
|
|
12
|
+
- When the API answers HTTP 400, 402 or 403 with "credit balance is too low", the gateway skips that key for the cooldown (60 minutes by default), logs `gateway: <label> out of credits; moving on`, and retries the same request with the next key. After the cooldown it tries the key again, which is how a key comes back once its org's credits refresh. A rejected credit check is not billed.
|
|
13
|
+
- The skip list is saved in `gateway-state.json` in the keys folder (mode 0600), so a restart remembers it. Delete that file to retry every key at once.
|
|
14
|
+
- When every key is out of credits, clients get HTTP 400 with the API's last credit-balance error.
|
|
15
|
+
- If the API cannot be reached, clients get HTTP 502 and the gateway does not move to the next key.
|
|
16
|
+
- Every request logs one line: `gateway: <label> <method> <path> <status> <ms>ms`. Read them with `docker logs ouro-credit-gateway`.
|
|
17
|
+
|
|
18
|
+
## One-time setup (as root on the Unraid host)
|
|
19
|
+
|
|
20
|
+
1. Create the keys folder, owned by the image's user (UID/GID 10001) and closed to everyone else:
|
|
21
|
+
|
|
22
|
+
```sh
|
|
23
|
+
install -d -o 10001 -g 10001 -m 0700 /mnt/user/appdata/credit-gateway /mnt/user/appdata/credit-gateway/keys
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
2. Put one upstream API key per account in the folder, one key per file, each file mode 0600. Copy the key files in rather than pasting keys on the command line, so they stay out of shell history:
|
|
27
|
+
|
|
28
|
+
```sh
|
|
29
|
+
cd /mnt/user/appdata/credit-gateway/keys
|
|
30
|
+
install -o 10001 -g 10001 -m 0600 /path/to/acct1.key acct1.key
|
|
31
|
+
install -o 10001 -g 10001 -m 0600 /path/to/acct2.key acct2.key
|
|
32
|
+
install -o 10001 -g 10001 -m 0600 /path/to/acct3.key acct3.key
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
3. Generate the master key:
|
|
36
|
+
|
|
37
|
+
```sh
|
|
38
|
+
cd /mnt/user/appdata/credit-gateway/keys
|
|
39
|
+
(umask 077; printf 'ouro-gw-%s\n' "$(openssl rand -hex 32)" > master.key)
|
|
40
|
+
chown 10001:10001 master.key
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
4. Check the result. Every file should be `-rw------- 10001 10001`:
|
|
44
|
+
|
|
45
|
+
```sh
|
|
46
|
+
ls -ln /mnt/user/appdata/credit-gateway/keys
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
The folder must stay writable by UID 10001, because the gateway saves `gateway-state.json` there.
|
|
50
|
+
|
|
51
|
+
## Run the gateway
|
|
52
|
+
|
|
53
|
+
Use the same image tag the Butler runs (the `<Repository>` in `sanctuary.xml`). The image's entrypoint starts the agent daemon, so override it with `--entrypoint node`. The image's built-in health check probes the agent, so replace it with a check of the gateway's own health URL:
|
|
54
|
+
|
|
55
|
+
```sh
|
|
56
|
+
docker run -d --name ouro-credit-gateway \
|
|
57
|
+
--network host \
|
|
58
|
+
--restart unless-stopped \
|
|
59
|
+
--entrypoint node \
|
|
60
|
+
-v /mnt/user/appdata/credit-gateway/keys:/keys \
|
|
61
|
+
--health-cmd 'curl -fsS http://127.0.0.1:8787/health || exit 1' \
|
|
62
|
+
--health-interval 30s --health-timeout 5s --health-retries 3 \
|
|
63
|
+
ghcr.io/ourostack/ouroboros-butler:<same tag as ouro-butler> \
|
|
64
|
+
/opt/ouro/dist/heart/daemon/ouro-entry.js gateway serve --keys-dir /keys --host 127.0.0.1 --port 8787
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Port 8787 is also the default port of Readarr and its forks (such as Bookshelf). If `ss -ltn | grep 8787` shows it in use, pick a free port (for example 8788) and use it in `--port`, the health check and the Butler's `--base-url`.
|
|
68
|
+
|
|
69
|
+
Optional flags after `gateway serve`: `--cooldown-minutes <n>` (default 60), `--state <path>` (default `<keys-dir>/gateway-state.json`), `--upstream <url>` (default `https://api.anthropic.com`).
|
|
70
|
+
|
|
71
|
+
## Check it
|
|
72
|
+
|
|
73
|
+
```sh
|
|
74
|
+
docker inspect --format '{{.State.Status}} {{.State.Health.Status}}' ouro-credit-gateway
|
|
75
|
+
curl -fsS http://127.0.0.1:8787/health
|
|
76
|
+
docker logs --tail 20 ouro-credit-gateway
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
`/health` needs no key and returns, for example, `{"ok":true,"keys":[{"label":"acct1","available":true},{"label":"acct2","available":false,"skipUntil":"2026-11-01T10:00:00.000Z"},{"label":"acct3","available":true}]}`.
|
|
80
|
+
|
|
81
|
+
## Point the Butler at it
|
|
82
|
+
|
|
83
|
+
Store the master key and the gateway URL as the Butler's Anthropic credentials (API-key mode). The key is read from a file or standard input, never from the command line:
|
|
84
|
+
|
|
85
|
+
```sh
|
|
86
|
+
docker exec -i -u 10001 ouro-butler node /opt/ouro/dist/heart/daemon/ouro-entry.js \
|
|
87
|
+
auth --agent sanctuary --provider anthropic --api-key-file /dev/stdin --base-url http://127.0.0.1:8787 \
|
|
88
|
+
< /mnt/user/appdata/credit-gateway/keys/master.key
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Then select an Anthropic model for the Butler's lanes with `ouro use`. In API-key mode the agent sends a plain API key, with no Claude Code identity headers, no Claude Code system preamble and no token refresh.
|
|
92
|
+
|
|
93
|
+
## Change or rotate keys
|
|
94
|
+
|
|
95
|
+
Replace the file (keep owner 10001 and mode 0600), then `docker restart ouro-credit-gateway`. After rotating `master.key`, run the `auth` step above again so the Butler sends the new master key.
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
<?xml version="1.0"?>
|
|
2
2
|
<Container version="2">
|
|
3
3
|
<Name>ouro-butler</Name>
|
|
4
|
-
<Repository>ghcr.io/ourostack/ouroboros-butler:0.1.0-alpha.
|
|
4
|
+
<Repository>ghcr.io/ourostack/ouroboros-butler:0.1.0-alpha.956</Repository>
|
|
5
5
|
<Registry>https://github.com/ourostack/ouroboros/pkgs/container/ouroboros-butler</Registry>
|
|
6
6
|
<Network>host</Network>
|
|
7
7
|
<Shell>sh</Shell>
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.loadGatewayKeys = loadGatewayKeys;
|
|
37
|
+
exports.loadGatewayState = loadGatewayState;
|
|
38
|
+
exports.saveGatewayState = saveGatewayState;
|
|
39
|
+
const fs = __importStar(require("node:fs"));
|
|
40
|
+
const path = __importStar(require("node:path"));
|
|
41
|
+
const runtime_1 = require("../nerves/runtime");
|
|
42
|
+
const MASTER_KEY_FILE = "master.key";
|
|
43
|
+
const KEY_SUFFIX = ".key";
|
|
44
|
+
function readTrimmed(filePath) {
|
|
45
|
+
try {
|
|
46
|
+
return fs.readFileSync(filePath, "utf8").trim();
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return "";
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Reads the master key and the upstream keys from a directory, once. Every `*.key` file other than
|
|
54
|
+
* `master.key` is an upstream key; the gateway drains them in file-name order.
|
|
55
|
+
*/
|
|
56
|
+
function loadGatewayKeys(keysDir) {
|
|
57
|
+
let entries;
|
|
58
|
+
try {
|
|
59
|
+
entries = fs.readdirSync(keysDir);
|
|
60
|
+
}
|
|
61
|
+
catch (error) {
|
|
62
|
+
/* v8 ignore next -- defensive: fs always throws Error instances @preserve */
|
|
63
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
64
|
+
throw new Error(`gateway: cannot read keys directory ${keysDir}: ${reason}`);
|
|
65
|
+
}
|
|
66
|
+
const masterKey = readTrimmed(path.join(keysDir, MASTER_KEY_FILE));
|
|
67
|
+
if (!masterKey)
|
|
68
|
+
throw new Error(`gateway: ${path.join(keysDir, MASTER_KEY_FILE)} is missing or empty`);
|
|
69
|
+
const upstream = [];
|
|
70
|
+
for (const name of entries.filter((entry) => entry.endsWith(KEY_SUFFIX) && entry !== MASTER_KEY_FILE).sort()) {
|
|
71
|
+
const key = readTrimmed(path.join(keysDir, name));
|
|
72
|
+
if (!key)
|
|
73
|
+
throw new Error(`gateway: upstream key file ${path.join(keysDir, name)} is empty`);
|
|
74
|
+
upstream.push({ label: name.slice(0, -KEY_SUFFIX.length), key });
|
|
75
|
+
}
|
|
76
|
+
if (upstream.length === 0)
|
|
77
|
+
throw new Error(`gateway: no upstream *.key files in ${keysDir} (besides master.key)`);
|
|
78
|
+
(0, runtime_1.emitNervesEvent)({
|
|
79
|
+
component: "gateway",
|
|
80
|
+
event: "gateway.keys_loaded",
|
|
81
|
+
message: "credit gateway keys loaded",
|
|
82
|
+
meta: { keysDir, labels: upstream.map((entry) => entry.label) },
|
|
83
|
+
});
|
|
84
|
+
return { masterKey, upstream };
|
|
85
|
+
}
|
|
86
|
+
/** Loads the skip list. A missing or corrupt file starts empty; entries that are not valid times are dropped. */
|
|
87
|
+
function loadGatewayState(statePath) {
|
|
88
|
+
const exhausted = {};
|
|
89
|
+
let parsed;
|
|
90
|
+
try {
|
|
91
|
+
parsed = JSON.parse(fs.readFileSync(statePath, "utf8"));
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
return { exhausted };
|
|
95
|
+
}
|
|
96
|
+
const raw = parsed && typeof parsed === "object" ? parsed.exhausted : undefined;
|
|
97
|
+
if (raw && typeof raw === "object" && !Array.isArray(raw)) {
|
|
98
|
+
for (const [label, until] of Object.entries(raw)) {
|
|
99
|
+
if (typeof until === "string" && Number.isFinite(Date.parse(until)))
|
|
100
|
+
exhausted[label] = until;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return { exhausted };
|
|
104
|
+
}
|
|
105
|
+
/** Writes the skip list with mode 0600, through a temporary file so a crash never leaves half a file. */
|
|
106
|
+
function saveGatewayState(statePath, state) {
|
|
107
|
+
const tmpPath = `${statePath}.${process.pid}.tmp`;
|
|
108
|
+
fs.writeFileSync(tmpPath, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 });
|
|
109
|
+
fs.chmodSync(tmpPath, 0o600);
|
|
110
|
+
fs.renameSync(tmpPath, statePath);
|
|
111
|
+
(0, runtime_1.emitNervesEvent)({
|
|
112
|
+
component: "gateway",
|
|
113
|
+
event: "gateway.state_saved",
|
|
114
|
+
message: "credit gateway state saved",
|
|
115
|
+
meta: { statePath, exhausted: Object.keys(state.exhausted) },
|
|
116
|
+
});
|
|
117
|
+
}
|
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.GATEWAY_MAX_REQUEST_BYTES = exports.GATEWAY_DEFAULT_COOLDOWN_MS = exports.GATEWAY_DEFAULT_UPSTREAM = exports.GATEWAY_DEFAULT_PORT = exports.GATEWAY_DEFAULT_HOST = void 0;
|
|
37
|
+
exports.isCreditExhaustedBody = isCreditExhaustedBody;
|
|
38
|
+
exports.creditGatewayOptionsFromCli = creditGatewayOptionsFromCli;
|
|
39
|
+
exports.startCreditGateway = startCreditGateway;
|
|
40
|
+
exports.waitForShutdownSignal = waitForShutdownSignal;
|
|
41
|
+
exports.runCreditGatewayCommand = runCreditGatewayCommand;
|
|
42
|
+
const http = __importStar(require("node:http"));
|
|
43
|
+
const https = __importStar(require("node:https"));
|
|
44
|
+
const path = __importStar(require("node:path"));
|
|
45
|
+
const zlib = __importStar(require("node:zlib"));
|
|
46
|
+
const node_crypto_1 = require("node:crypto");
|
|
47
|
+
const node_stream_1 = require("node:stream");
|
|
48
|
+
const runtime_1 = require("../nerves/runtime");
|
|
49
|
+
const keys_1 = require("./keys");
|
|
50
|
+
exports.GATEWAY_DEFAULT_HOST = "127.0.0.1";
|
|
51
|
+
exports.GATEWAY_DEFAULT_PORT = 8787;
|
|
52
|
+
exports.GATEWAY_DEFAULT_UPSTREAM = "https://api.anthropic.com";
|
|
53
|
+
exports.GATEWAY_DEFAULT_COOLDOWN_MS = 60 * 60 * 1000;
|
|
54
|
+
exports.GATEWAY_MAX_REQUEST_BYTES = 32 * 1024 * 1024;
|
|
55
|
+
const CREDIT_LOW_PHRASE = "credit balance is too low";
|
|
56
|
+
const CREDIT_CHECK_STATUSES = new Set([400, 402, 403]);
|
|
57
|
+
const HOP_BY_HOP_HEADERS = new Set([
|
|
58
|
+
"connection",
|
|
59
|
+
"keep-alive",
|
|
60
|
+
"proxy-authenticate",
|
|
61
|
+
"proxy-authorization",
|
|
62
|
+
"proxy-connection",
|
|
63
|
+
"te",
|
|
64
|
+
"trailer",
|
|
65
|
+
"transfer-encoding",
|
|
66
|
+
"upgrade",
|
|
67
|
+
]);
|
|
68
|
+
class RequestTooLargeError extends Error {
|
|
69
|
+
}
|
|
70
|
+
function anthropicError(type, message) {
|
|
71
|
+
return JSON.stringify({ type: "error", error: { type, message } });
|
|
72
|
+
}
|
|
73
|
+
/* v8 ignore start -- defensive: Node and the HTTP stack always throw Error instances @preserve */
|
|
74
|
+
function errorMessage(error) {
|
|
75
|
+
return error instanceof Error ? error.message : String(error);
|
|
76
|
+
}
|
|
77
|
+
/* v8 ignore stop */
|
|
78
|
+
function digest(value) {
|
|
79
|
+
return (0, node_crypto_1.createHash)("sha256").update(value).digest();
|
|
80
|
+
}
|
|
81
|
+
/** Compares digests so the comparison takes the same time whatever the presented key's length. */
|
|
82
|
+
function matchesMasterKey(presented, masterDigest) {
|
|
83
|
+
if (!presented)
|
|
84
|
+
return false;
|
|
85
|
+
return (0, node_crypto_1.timingSafeEqual)(digest(presented), masterDigest);
|
|
86
|
+
}
|
|
87
|
+
function presentedKey(req) {
|
|
88
|
+
const apiKey = req.headers["x-api-key"];
|
|
89
|
+
if (typeof apiKey === "string" && apiKey.trim())
|
|
90
|
+
return apiKey.trim();
|
|
91
|
+
const bearer = /^Bearer\s+(.+)$/i.exec(req.headers.authorization ?? "");
|
|
92
|
+
return bearer ? bearer[1].trim() : undefined;
|
|
93
|
+
}
|
|
94
|
+
function readRequestBody(req, maxBytes) {
|
|
95
|
+
return new Promise((resolve, reject) => {
|
|
96
|
+
const chunks = [];
|
|
97
|
+
let size = 0;
|
|
98
|
+
req.on("data", (chunk) => {
|
|
99
|
+
size += chunk.length;
|
|
100
|
+
if (size > maxBytes) {
|
|
101
|
+
reject(new RequestTooLargeError());
|
|
102
|
+
req.pause();
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
chunks.push(chunk);
|
|
106
|
+
});
|
|
107
|
+
req.on("end", () => resolve(Buffer.concat(chunks)));
|
|
108
|
+
req.on("error", reject);
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
function readAll(stream) {
|
|
112
|
+
return new Promise((resolve, reject) => {
|
|
113
|
+
const chunks = [];
|
|
114
|
+
stream.on("data", (chunk) => chunks.push(chunk));
|
|
115
|
+
stream.on("end", () => resolve(Buffer.concat(chunks)));
|
|
116
|
+
stream.on("error", reject);
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
function forwardedRequestHeaders(req, upstreamKey, bodyLength) {
|
|
120
|
+
const headers = {};
|
|
121
|
+
for (const [name, value] of Object.entries(req.headers)) {
|
|
122
|
+
if (HOP_BY_HOP_HEADERS.has(name) || name === "host" || name === "content-length" || name === "x-api-key" || name === "authorization")
|
|
123
|
+
continue;
|
|
124
|
+
headers[name] = value;
|
|
125
|
+
}
|
|
126
|
+
headers["x-api-key"] = upstreamKey;
|
|
127
|
+
if (bodyLength > 0)
|
|
128
|
+
headers["content-length"] = String(bodyLength);
|
|
129
|
+
return headers;
|
|
130
|
+
}
|
|
131
|
+
function responseHeaders(headers) {
|
|
132
|
+
const result = {};
|
|
133
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
134
|
+
if (HOP_BY_HOP_HEADERS.has(name))
|
|
135
|
+
continue;
|
|
136
|
+
result[name] = value;
|
|
137
|
+
}
|
|
138
|
+
return result;
|
|
139
|
+
}
|
|
140
|
+
/** Decodes a compressed body for inspection only; the client still gets the original bytes. */
|
|
141
|
+
function decodeForInspection(body, encoding) {
|
|
142
|
+
try {
|
|
143
|
+
const normalized = (encoding ?? "").trim().toLowerCase();
|
|
144
|
+
if (normalized === "gzip" || normalized === "x-gzip")
|
|
145
|
+
return zlib.gunzipSync(body).toString("utf8");
|
|
146
|
+
if (normalized === "deflate")
|
|
147
|
+
return zlib.inflateSync(body).toString("utf8");
|
|
148
|
+
if (normalized === "br")
|
|
149
|
+
return zlib.brotliDecompressSync(body).toString("utf8");
|
|
150
|
+
return body.toString("utf8");
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
return body.toString("utf8");
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
function isCreditExhaustedBody(body, encoding) {
|
|
157
|
+
return decodeForInspection(body, encoding).toLowerCase().includes(CREDIT_LOW_PHRASE);
|
|
158
|
+
}
|
|
159
|
+
function upstreamUrl(base, requestUrl) {
|
|
160
|
+
const prefix = base.pathname.replace(/\/+$/, "");
|
|
161
|
+
return new URL(`${prefix}${requestUrl}`, base.origin);
|
|
162
|
+
}
|
|
163
|
+
function sendUpstream(target, method, headers, body) {
|
|
164
|
+
return new Promise((resolve, reject) => {
|
|
165
|
+
const transport = target.protocol === "https:" ? https : http;
|
|
166
|
+
const request = transport.request(target, { method, headers }, (response) => resolve({ response, request }));
|
|
167
|
+
request.on("error", reject);
|
|
168
|
+
request.end(body.length > 0 ? body : undefined);
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
function writeBuffered(res, buffered) {
|
|
172
|
+
res.writeHead(buffered.status, { ...buffered.headers, "content-length": String(buffered.body.length) });
|
|
173
|
+
res.end(buffered.body);
|
|
174
|
+
}
|
|
175
|
+
function writeJsonError(res, status, type, message) {
|
|
176
|
+
const body = Buffer.from(anthropicError(type, message));
|
|
177
|
+
writeBuffered(res, { status, headers: { "content-type": "application/json" }, body });
|
|
178
|
+
}
|
|
179
|
+
/** Maps `ouro gateway serve` flags to gateway options. */
|
|
180
|
+
function creditGatewayOptionsFromCli(command) {
|
|
181
|
+
return {
|
|
182
|
+
keysDir: command.keysDir,
|
|
183
|
+
...(command.statePath ? { statePath: command.statePath } : {}),
|
|
184
|
+
...(command.host ? { host: command.host } : {}),
|
|
185
|
+
...(command.port !== undefined ? { port: command.port } : {}),
|
|
186
|
+
...(command.upstream ? { upstream: command.upstream } : {}),
|
|
187
|
+
...(command.cooldownMinutes !== undefined ? { cooldownMs: command.cooldownMinutes * 60 * 1000 } : {}),
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
/* v8 ignore start -- the default log writes to the real stderr; tests inject log @preserve */
|
|
191
|
+
function defaultLog(line) {
|
|
192
|
+
process.stderr.write(`${line}\n`);
|
|
193
|
+
}
|
|
194
|
+
/* v8 ignore stop */
|
|
195
|
+
/**
|
|
196
|
+
* Starts the credit gateway: a byte-passthrough proxy to the Anthropic API that accepts one master key
|
|
197
|
+
* and spends the upstream keys in a fixed order, moving to the next key only when the current one
|
|
198
|
+
* reports that its credit balance is too low.
|
|
199
|
+
*/
|
|
200
|
+
async function startCreditGateway(options) {
|
|
201
|
+
const keys = (0, keys_1.loadGatewayKeys)(options.keysDir);
|
|
202
|
+
const statePath = options.statePath ?? path.join(options.keysDir, "gateway-state.json");
|
|
203
|
+
const state = (0, keys_1.loadGatewayState)(statePath);
|
|
204
|
+
const host = options.host ?? exports.GATEWAY_DEFAULT_HOST;
|
|
205
|
+
const upstreamBase = new URL(options.upstream ?? exports.GATEWAY_DEFAULT_UPSTREAM);
|
|
206
|
+
const cooldownMs = options.cooldownMs ?? exports.GATEWAY_DEFAULT_COOLDOWN_MS;
|
|
207
|
+
const maxRequestBytes = options.maxRequestBytes ?? exports.GATEWAY_MAX_REQUEST_BYTES;
|
|
208
|
+
const now = options.now ?? Date.now;
|
|
209
|
+
const log = options.log ?? defaultLog;
|
|
210
|
+
const masterDigest = digest(keys.masterKey);
|
|
211
|
+
function persist() {
|
|
212
|
+
try {
|
|
213
|
+
(0, keys_1.saveGatewayState)(statePath, state);
|
|
214
|
+
}
|
|
215
|
+
catch (error) {
|
|
216
|
+
(0, runtime_1.emitNervesEvent)({
|
|
217
|
+
level: "warn",
|
|
218
|
+
component: "gateway",
|
|
219
|
+
event: "gateway.state_save_error",
|
|
220
|
+
message: "credit gateway could not save its state",
|
|
221
|
+
meta: { statePath, error: errorMessage(error) },
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
/** Returns the skip-until time for a label, or undefined when the key is usable. Expired entries are dropped. */
|
|
226
|
+
function skipUntil(label) {
|
|
227
|
+
const until = state.exhausted[label];
|
|
228
|
+
if (!until)
|
|
229
|
+
return undefined;
|
|
230
|
+
if (Date.parse(until) > now())
|
|
231
|
+
return until;
|
|
232
|
+
delete state.exhausted[label];
|
|
233
|
+
persist();
|
|
234
|
+
return undefined;
|
|
235
|
+
}
|
|
236
|
+
function markExhausted(entry) {
|
|
237
|
+
state.exhausted[entry.label] = new Date(now() + cooldownMs).toISOString();
|
|
238
|
+
persist();
|
|
239
|
+
log(`gateway: ${entry.label} out of credits; moving on`);
|
|
240
|
+
(0, runtime_1.emitNervesEvent)({
|
|
241
|
+
level: "warn",
|
|
242
|
+
component: "gateway",
|
|
243
|
+
event: "gateway.key_exhausted",
|
|
244
|
+
message: "credit gateway key is out of credits",
|
|
245
|
+
meta: { label: entry.label, skipUntil: state.exhausted[entry.label] },
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
function logRequest(label, method, pathname, status, startedAt) {
|
|
249
|
+
const ms = now() - startedAt;
|
|
250
|
+
log(`gateway: ${label} ${method} ${pathname} ${status} ${ms}ms`);
|
|
251
|
+
(0, runtime_1.emitNervesEvent)({
|
|
252
|
+
component: "gateway",
|
|
253
|
+
event: "gateway.request",
|
|
254
|
+
message: "credit gateway request",
|
|
255
|
+
meta: { label, method, path: pathname, status, ms },
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
async function handle(req, res) {
|
|
259
|
+
const startedAt = now();
|
|
260
|
+
/* v8 ignore start -- Node always supplies url and method for accepted HTTP requests @preserve */
|
|
261
|
+
const requestUrl = req.url ?? "/";
|
|
262
|
+
const method = req.method ?? "GET";
|
|
263
|
+
/* v8 ignore stop */
|
|
264
|
+
const pathname = new URL(requestUrl, "http://gateway.local").pathname;
|
|
265
|
+
if (method === "GET" && pathname === "/health") {
|
|
266
|
+
const body = JSON.stringify({
|
|
267
|
+
ok: true,
|
|
268
|
+
keys: keys.upstream.map((entry) => {
|
|
269
|
+
const until = skipUntil(entry.label);
|
|
270
|
+
return until ? { label: entry.label, available: false, skipUntil: until } : { label: entry.label, available: true };
|
|
271
|
+
}),
|
|
272
|
+
});
|
|
273
|
+
writeBuffered(res, { status: 200, headers: { "content-type": "application/json" }, body: Buffer.from(body) });
|
|
274
|
+
logRequest("-", method, pathname, 200, startedAt);
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
if (!matchesMasterKey(presentedKey(req), masterDigest)) {
|
|
278
|
+
writeJsonError(res, 401, "authentication_error", "invalid x-api-key");
|
|
279
|
+
logRequest("-", method, pathname, 401, startedAt);
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
const target = upstreamUrl(upstreamBase, requestUrl);
|
|
283
|
+
if (target.origin !== upstreamBase.origin) {
|
|
284
|
+
writeJsonError(res, 400, "invalid_request_error", "invalid request path");
|
|
285
|
+
logRequest("-", method, pathname, 400, startedAt);
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
let body;
|
|
289
|
+
try {
|
|
290
|
+
body = await readRequestBody(req, maxRequestBytes);
|
|
291
|
+
}
|
|
292
|
+
catch (error) {
|
|
293
|
+
/* v8 ignore start -- the client went away while sending its body; there is no one to answer @preserve */
|
|
294
|
+
if (!(error instanceof RequestTooLargeError)) {
|
|
295
|
+
res.destroy();
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
/* v8 ignore stop */
|
|
299
|
+
res.setHeader("connection", "close");
|
|
300
|
+
writeJsonError(res, 413, "request_too_large", `request body exceeds ${maxRequestBytes} bytes`);
|
|
301
|
+
logRequest("-", method, pathname, 413, startedAt);
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
let lastCredit;
|
|
305
|
+
let lastCreditLabel = "-";
|
|
306
|
+
for (const entry of keys.upstream) {
|
|
307
|
+
if (skipUntil(entry.label))
|
|
308
|
+
continue;
|
|
309
|
+
let upstream;
|
|
310
|
+
try {
|
|
311
|
+
upstream = await sendUpstream(target, method, forwardedRequestHeaders(req, entry.key, body.length), body);
|
|
312
|
+
}
|
|
313
|
+
catch (error) {
|
|
314
|
+
(0, runtime_1.emitNervesEvent)({
|
|
315
|
+
level: "error",
|
|
316
|
+
component: "gateway",
|
|
317
|
+
event: "gateway.upstream_error",
|
|
318
|
+
message: "credit gateway could not reach the upstream API",
|
|
319
|
+
meta: { label: entry.label, error: errorMessage(error) },
|
|
320
|
+
});
|
|
321
|
+
writeJsonError(res, 502, "api_error", "credit gateway could not reach the upstream API");
|
|
322
|
+
logRequest(entry.label, method, pathname, 502, startedAt);
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
/* v8 ignore next -- Node always sets statusCode on an upstream response @preserve */
|
|
326
|
+
const status = upstream.response.statusCode ?? 502;
|
|
327
|
+
if (CREDIT_CHECK_STATUSES.has(status)) {
|
|
328
|
+
const buffered = {
|
|
329
|
+
status,
|
|
330
|
+
headers: responseHeaders(upstream.response.headers),
|
|
331
|
+
body: await readAll(upstream.response),
|
|
332
|
+
};
|
|
333
|
+
if (isCreditExhaustedBody(buffered.body, upstream.response.headers["content-encoding"])) {
|
|
334
|
+
markExhausted(entry);
|
|
335
|
+
lastCredit = buffered;
|
|
336
|
+
lastCreditLabel = entry.label;
|
|
337
|
+
continue;
|
|
338
|
+
}
|
|
339
|
+
writeBuffered(res, buffered);
|
|
340
|
+
logRequest(entry.label, method, pathname, status, startedAt);
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
res.writeHead(status, responseHeaders(upstream.response.headers));
|
|
344
|
+
res.flushHeaders();
|
|
345
|
+
(0, node_stream_1.pipeline)(upstream.response, res, (error) => {
|
|
346
|
+
if (error)
|
|
347
|
+
upstream.request.destroy();
|
|
348
|
+
logRequest(entry.label, method, pathname, status, startedAt);
|
|
349
|
+
});
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
if (lastCredit) {
|
|
353
|
+
writeBuffered(res, { ...lastCredit, status: 400 });
|
|
354
|
+
}
|
|
355
|
+
else {
|
|
356
|
+
writeJsonError(res, 400, "invalid_request_error", "Your credit balance is too low on every gateway key");
|
|
357
|
+
}
|
|
358
|
+
logRequest(lastCreditLabel, method, pathname, 400, startedAt);
|
|
359
|
+
}
|
|
360
|
+
const server = http.createServer((req, res) => {
|
|
361
|
+
handle(req, res).catch((error) => {
|
|
362
|
+
(0, runtime_1.emitNervesEvent)({
|
|
363
|
+
level: "error",
|
|
364
|
+
component: "gateway",
|
|
365
|
+
event: "gateway.request_error",
|
|
366
|
+
message: "credit gateway request failed",
|
|
367
|
+
meta: { error: errorMessage(error) },
|
|
368
|
+
});
|
|
369
|
+
/* v8 ignore next 4 -- failures after the headers went out are reported by the stream pipeline instead @preserve */
|
|
370
|
+
if (res.headersSent) {
|
|
371
|
+
res.destroy();
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
writeJsonError(res, 502, "api_error", "credit gateway request failed");
|
|
375
|
+
});
|
|
376
|
+
});
|
|
377
|
+
await new Promise((resolve, reject) => {
|
|
378
|
+
server.once("error", reject);
|
|
379
|
+
/* v8 ignore next -- tests always bind port 0; the default is for production @preserve */
|
|
380
|
+
const port = options.port ?? exports.GATEWAY_DEFAULT_PORT;
|
|
381
|
+
server.listen(port, host, () => {
|
|
382
|
+
server.off("error", reject);
|
|
383
|
+
resolve();
|
|
384
|
+
});
|
|
385
|
+
});
|
|
386
|
+
const address = server.address();
|
|
387
|
+
const url = `http://${host}:${address.port}`;
|
|
388
|
+
(0, runtime_1.emitNervesEvent)({
|
|
389
|
+
component: "gateway",
|
|
390
|
+
event: "gateway.server_started",
|
|
391
|
+
message: "credit gateway started",
|
|
392
|
+
meta: { url, upstream: upstreamBase.origin, labels: keys.upstream.map((entry) => entry.label), statePath },
|
|
393
|
+
});
|
|
394
|
+
return {
|
|
395
|
+
server,
|
|
396
|
+
url,
|
|
397
|
+
port: address.port,
|
|
398
|
+
close: () => new Promise((resolve) => {
|
|
399
|
+
server.closeAllConnections();
|
|
400
|
+
server.close(() => {
|
|
401
|
+
(0, runtime_1.emitNervesEvent)({
|
|
402
|
+
component: "gateway",
|
|
403
|
+
event: "gateway.server_stopped",
|
|
404
|
+
message: "credit gateway stopped",
|
|
405
|
+
meta: { url },
|
|
406
|
+
});
|
|
407
|
+
resolve();
|
|
408
|
+
});
|
|
409
|
+
}),
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
/* v8 ignore start -- waits for real process signals; tests inject waitForStop @preserve */
|
|
413
|
+
function waitForShutdownSignal() {
|
|
414
|
+
return new Promise((resolve) => {
|
|
415
|
+
process.once("SIGINT", () => resolve());
|
|
416
|
+
process.once("SIGTERM", () => resolve());
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
/* v8 ignore stop */
|
|
420
|
+
/** Runs `ouro gateway serve` in the foreground until `waitForStop` resolves. */
|
|
421
|
+
async function runCreditGatewayCommand(command, deps) {
|
|
422
|
+
const handle = await startCreditGateway(creditGatewayOptionsFromCli(command));
|
|
423
|
+
const message = `credit gateway listening on ${handle.url}\nhealth: ${handle.url}/health`;
|
|
424
|
+
deps.writeStdout(message);
|
|
425
|
+
/* v8 ignore next -- production waits for SIGINT/SIGTERM; tests inject waitForStop @preserve */
|
|
426
|
+
await (deps.waitForStop ?? waitForShutdownSignal)();
|
|
427
|
+
await handle.close();
|
|
428
|
+
return message;
|
|
429
|
+
}
|
|
@@ -361,6 +361,16 @@ async function collectRuntimeAuthCredentials(input, deps) {
|
|
|
361
361
|
}
|
|
362
362
|
return credentials;
|
|
363
363
|
}
|
|
364
|
+
if (input.provider === "anthropic" && input.apiKey) {
|
|
365
|
+
(0, runtime_1.emitNervesEvent)({
|
|
366
|
+
component: "daemon",
|
|
367
|
+
event: "daemon.auth_anthropic_api_key",
|
|
368
|
+
message: "storing anthropic API key for runtime auth",
|
|
369
|
+
meta: { agentName: input.agentName, hasBaseUrl: !!input.baseUrl },
|
|
370
|
+
});
|
|
371
|
+
writeAuthProgress(input, "using the supplied anthropic API key...");
|
|
372
|
+
return { apiKey: input.apiKey.trim(), ...(input.baseUrl ? { baseUrl: input.baseUrl } : {}) };
|
|
373
|
+
}
|
|
364
374
|
if (input.provider === "anthropic") {
|
|
365
375
|
(0, runtime_1.emitNervesEvent)({
|
|
366
376
|
component: "daemon",
|
|
@@ -414,12 +424,16 @@ async function collectRuntimeAuthCredentials(input, deps) {
|
|
|
414
424
|
}
|
|
415
425
|
return creds;
|
|
416
426
|
}
|
|
427
|
+
/** True when the record holds `required` or one of the provider's alternative field sets in full. */
|
|
428
|
+
function hasRequiredProviderCredentials(provider, record) {
|
|
429
|
+
const requirements = identity_1.PROVIDER_CREDENTIALS[provider];
|
|
430
|
+
return [requirements.required, ...(requirements.alternativeRequired ?? [])].some((fields) => fields.every((key) => !!record[key]));
|
|
431
|
+
}
|
|
417
432
|
async function resolveHatchCredentials(input) {
|
|
418
433
|
const credentials = { ...(input.credentials ?? {}) };
|
|
419
434
|
// If all required fields are already provided, return as-is
|
|
420
435
|
const cred = credentials;
|
|
421
|
-
|
|
422
|
-
if (!missing)
|
|
436
|
+
if (hasRequiredProviderCredentials(input.provider, cred))
|
|
423
437
|
return credentials;
|
|
424
438
|
// Try the full auth flow (wraps collectRuntimeAuthCredentials + writes secrets)
|
|
425
439
|
if (input.runAuthFlow) {
|
|
@@ -431,7 +445,7 @@ async function resolveHatchCredentials(input) {
|
|
|
431
445
|
});
|
|
432
446
|
Object.assign(credentials, result.credentials);
|
|
433
447
|
/* v8 ignore next 3 -- branch: auth flow always fills all required fields in production @preserve */
|
|
434
|
-
if (
|
|
448
|
+
if (hasRequiredProviderCredentials(input.provider, credentials)) {
|
|
435
449
|
return credentials;
|
|
436
450
|
}
|
|
437
451
|
}
|
package/dist/heart/config.js
CHANGED
|
@@ -235,7 +235,11 @@ function getMinimaxConfig() {
|
|
|
235
235
|
}
|
|
236
236
|
function getAnthropicConfig() {
|
|
237
237
|
const raw = readProviderConfig("anthropic");
|
|
238
|
-
return {
|
|
238
|
+
return {
|
|
239
|
+
setupToken: typeof raw.setupToken === "string" ? raw.setupToken : "",
|
|
240
|
+
...(typeof raw.apiKey === "string" && raw.apiKey ? { apiKey: raw.apiKey } : {}),
|
|
241
|
+
...(typeof raw.baseUrl === "string" && raw.baseUrl ? { baseUrl: raw.baseUrl } : {}),
|
|
242
|
+
};
|
|
239
243
|
}
|
|
240
244
|
function getOpenAICodexConfig() {
|
|
241
245
|
const raw = readProviderConfig("openai-codex");
|
|
@@ -5648,8 +5648,24 @@ async function executeProviderRefresh(command, deps) {
|
|
|
5648
5648
|
deps.writeStdout(message);
|
|
5649
5649
|
return message;
|
|
5650
5650
|
}
|
|
5651
|
+
/** Reads an API key from a file (or /dev/stdin) so it never appears in argv. */
|
|
5652
|
+
function readApiKeyFile(filePath) {
|
|
5653
|
+
let raw;
|
|
5654
|
+
try {
|
|
5655
|
+
raw = fs.readFileSync(filePath, "utf8");
|
|
5656
|
+
}
|
|
5657
|
+
catch (error) {
|
|
5658
|
+
/* v8 ignore next -- fs.readFileSync only throws Error objects @preserve */
|
|
5659
|
+
throw new Error(`could not read --api-key-file ${filePath}: ${error instanceof Error ? error.message : String(error)}`);
|
|
5660
|
+
}
|
|
5661
|
+
const key = raw.trim();
|
|
5662
|
+
if (!key)
|
|
5663
|
+
throw new Error(`--api-key-file ${filePath} is empty.`);
|
|
5664
|
+
return key;
|
|
5665
|
+
}
|
|
5651
5666
|
async function executeAuthRun(command, deps) {
|
|
5652
5667
|
const provider = command.provider ?? (0, auth_flow_1.readAgentConfigForAgent)(command.agent, deps.bundlesRoot).config.humanFacing.provider;
|
|
5668
|
+
const apiKey = command.apiKeyFile ? readApiKeyFile(command.apiKeyFile) : undefined;
|
|
5653
5669
|
/* v8 ignore next -- tests always inject runAuthFlow; default is for production @preserve */
|
|
5654
5670
|
const authRunner = deps.runAuthFlow ?? (await Promise.resolve().then(() => __importStar(require("../auth/auth-flow")))).runRuntimeAuthFlow;
|
|
5655
5671
|
const progress = createHumanCommandProgress(deps, "auth");
|
|
@@ -5662,6 +5678,8 @@ async function executeAuthRun(command, deps) {
|
|
|
5662
5678
|
promptInput: deps.promptInput,
|
|
5663
5679
|
promptSecret: deps.promptSecret,
|
|
5664
5680
|
onProgress: (message) => progress.updateDetail(message),
|
|
5681
|
+
...(apiKey ? { apiKey } : {}),
|
|
5682
|
+
...(command.baseUrl ? { baseUrl: command.baseUrl } : {}),
|
|
5665
5683
|
});
|
|
5666
5684
|
}
|
|
5667
5685
|
catch (error) {
|
|
@@ -8231,6 +8249,13 @@ async function runOuroCli(args, deps = (0, cli_defaults_1.createDefaultOuroCliDe
|
|
|
8231
8249
|
if (command.kind === "a2a.card" || command.kind === "a2a.onboard" || command.kind === "a2a.serve" || command.kind === "a2a.escalation" || command.kind === "a2a.delegatedCommands") {
|
|
8232
8250
|
return executeA2ACommand(command, deps);
|
|
8233
8251
|
}
|
|
8252
|
+
if (command.kind === "gateway.serve") {
|
|
8253
|
+
const { runCreditGatewayCommand } = await Promise.resolve().then(() => __importStar(require("../../gateway/server")));
|
|
8254
|
+
return runCreditGatewayCommand(command, {
|
|
8255
|
+
writeStdout: deps.writeStdout,
|
|
8256
|
+
...(deps.waitForShutdownSignal ? { waitForStop: deps.waitForShutdownSignal } : {}),
|
|
8257
|
+
});
|
|
8258
|
+
}
|
|
8234
8259
|
// ── provider commands (local, no daemon socket needed) ──
|
|
8235
8260
|
if (command.kind === "provider.use") {
|
|
8236
8261
|
return executeProviderUse(command, deps);
|
|
@@ -259,8 +259,8 @@ exports.COMMAND_REGISTRY = {
|
|
|
259
259
|
},
|
|
260
260
|
auth: {
|
|
261
261
|
category: "Auth",
|
|
262
|
-
description: "Set up or verify agent credentials",
|
|
263
|
-
usage: "ouro auth [verify] [--agent <name>] [--provider <provider>]",
|
|
262
|
+
description: "Set up or verify agent credentials. For Anthropic API-key mode (for example through a credit gateway), pass --api-key-file <path> (use /dev/stdin to pipe it) and optionally --base-url <url>; the key is never taken from the command line.",
|
|
263
|
+
usage: "ouro auth [verify] [--agent <name>] [--provider <provider>] [--api-key-file <path> [--base-url <url>]]",
|
|
264
264
|
example: "ouro auth",
|
|
265
265
|
subcommands: ["verify"],
|
|
266
266
|
},
|
|
@@ -358,6 +358,13 @@ exports.COMMAND_REGISTRY = {
|
|
|
358
358
|
example: "ouro session list",
|
|
359
359
|
subcommands: ["list"],
|
|
360
360
|
},
|
|
361
|
+
gateway: {
|
|
362
|
+
category: "System",
|
|
363
|
+
description: "Run the credit gateway: one master key for agents, upstream Anthropic API keys spent in a fixed order",
|
|
364
|
+
usage: "ouro gateway serve --keys-dir <dir> [--host <host>] [--port <port>]",
|
|
365
|
+
example: "ouro gateway serve --keys-dir /mnt/user/appdata/credit-gateway/keys",
|
|
366
|
+
subcommands: ["serve"],
|
|
367
|
+
},
|
|
361
368
|
mcp: {
|
|
362
369
|
category: "System",
|
|
363
370
|
description: "Interact with MCP servers",
|
|
@@ -497,6 +504,11 @@ const SUBCOMMAND_HELP = {
|
|
|
497
504
|
usage: "ouro a2a onboard [--agent <name>] (--card-url <url>|--did <did:key> --name <name>) [--trust <level>] [--name <name>]",
|
|
498
505
|
example: "ouro a2a onboard --agent <agent> --card-url https://peer.example/.well-known/agent-card.json --trust friend",
|
|
499
506
|
},
|
|
507
|
+
"gateway serve": {
|
|
508
|
+
description: "Run the credit gateway in the foreground. It accepts the master key from <keys-dir>/master.key and forwards each request to the Anthropic API with the first upstream *.key (by file name) whose credits are not exhausted; a key whose credit balance is too low is skipped for the cooldown (default 60 minutes). GET /health needs no key.",
|
|
509
|
+
usage: "ouro gateway serve --keys-dir <dir> [--host <host>] [--port <port>] [--state <path>] [--upstream <url>] [--cooldown-minutes <n>]",
|
|
510
|
+
example: "ouro gateway serve --keys-dir /mnt/user/appdata/credit-gateway/keys --port 8787",
|
|
511
|
+
},
|
|
500
512
|
"a2a serve": {
|
|
501
513
|
description: "Run this agent's local A2A JSON-RPC sense server",
|
|
502
514
|
usage: "ouro a2a serve [--agent <name>] [--host <host>] [--port <port>] [--base-url <url>] [--path <path>]",
|
|
@@ -254,6 +254,7 @@ function usage() {
|
|
|
254
254
|
" ouro mailbox [--json]",
|
|
255
255
|
" ouro -v|--version",
|
|
256
256
|
" ouro auth [--agent <name>] [--provider <provider>]",
|
|
257
|
+
" ouro auth --agent <name> --provider anthropic --api-key-file <path> [--base-url <url>]",
|
|
257
258
|
" ouro account ensure [--agent <name>] [--owner-email <email> --source <label>|--no-delegated-source] [--rotate-missing-mail-keys]",
|
|
258
259
|
" ouro connect [providers|perplexity|embeddings|teams|bluebubbles|mail|voice|a2a|telegram|workbench] [--agent <name>] [--owner-email <email> --source <label>|--no-delegated-source] [--rotate-missing-mail-keys]",
|
|
259
260
|
" ouro mail import-mbox --file <path> [--owner-email <email>] [--source <label>] [--agent <name>] [--foreground]",
|
|
@@ -303,6 +304,7 @@ function usage() {
|
|
|
303
304
|
" ouro a2a card [--agent <name>] [--base-url <url>] [--json]",
|
|
304
305
|
" ouro a2a onboard [--agent <name>] (--card-url <url>|--did <did:key> --name <name>) [--trust <level>] [--name <name>]",
|
|
305
306
|
" ouro a2a serve [--agent <name>] [--host <host>] [--port <port>] [--base-url <url>] [--path <path>]",
|
|
307
|
+
" ouro gateway serve --keys-dir <dir> [--host <host>] [--port <port>] [--state <path>] [--upstream <url>] [--cooldown-minutes <n>]",
|
|
306
308
|
" ouro a2a identity [--identity-file <path>] [--json]",
|
|
307
309
|
" ouro a2a message --to <card-url> --text <text> [--context <id>] [--delegated] [--identity-file <path>] [--json]",
|
|
308
310
|
" ouro a2a outbox list --to <card-url> [--since <cursor>] [--identity-file <path>] [--json]",
|
|
@@ -834,6 +836,8 @@ function parseAuthCommand(args) {
|
|
|
834
836
|
}
|
|
835
837
|
const { agent, rest } = extractAgentFlag(args);
|
|
836
838
|
let provider;
|
|
839
|
+
let apiKeyFile;
|
|
840
|
+
let baseUrl;
|
|
837
841
|
for (let i = 0; i < rest.length; i += 1) {
|
|
838
842
|
if (rest[i] === "--provider") {
|
|
839
843
|
const value = rest[i + 1];
|
|
@@ -843,11 +847,33 @@ function parseAuthCommand(args) {
|
|
|
843
847
|
i += 1;
|
|
844
848
|
continue;
|
|
845
849
|
}
|
|
850
|
+
// The API key itself is read from a file (or /dev/stdin), never from argv, so it stays out of
|
|
851
|
+
// shell history and process listings.
|
|
852
|
+
if (rest[i] === "--api-key-file" || rest[i] === "--base-url") {
|
|
853
|
+
const value = rest[i + 1];
|
|
854
|
+
if (!value || value.startsWith("--"))
|
|
855
|
+
throw new Error(`${rest[i]} requires a value.`);
|
|
856
|
+
if (rest[i] === "--api-key-file")
|
|
857
|
+
apiKeyFile = value;
|
|
858
|
+
else
|
|
859
|
+
baseUrl = value;
|
|
860
|
+
i += 1;
|
|
861
|
+
continue;
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
if ((apiKeyFile || baseUrl) && provider !== "anthropic") {
|
|
865
|
+
throw new Error("--api-key-file and --base-url are only for --provider anthropic (API-key mode).");
|
|
846
866
|
}
|
|
867
|
+
if (baseUrl && !apiKeyFile)
|
|
868
|
+
throw new Error("--base-url requires --api-key-file.");
|
|
869
|
+
if (baseUrl && !/^https?:\/\//.test(baseUrl))
|
|
870
|
+
throw new Error("--base-url must be an http(s) URL.");
|
|
847
871
|
return {
|
|
848
872
|
kind: "auth.run",
|
|
849
873
|
...(agent ? { agent } : {}),
|
|
850
874
|
...(provider ? { provider } : {}),
|
|
875
|
+
...(apiKeyFile ? { apiKeyFile } : {}),
|
|
876
|
+
...(baseUrl ? { baseUrl } : {}),
|
|
851
877
|
};
|
|
852
878
|
}
|
|
853
879
|
function isVaultUnlockStoreKind(value) {
|
|
@@ -1936,6 +1962,61 @@ function parseA2ACommand(args) {
|
|
|
1936
1962
|
}
|
|
1937
1963
|
throw new Error("Usage: ouro a2a card|onboard|serve|message|identity|outbox|escalation|delegated-commands ...");
|
|
1938
1964
|
}
|
|
1965
|
+
const GATEWAY_SERVE_USAGE = "Usage: ouro gateway serve --keys-dir <dir> [--host <host>] [--port <port>] [--state <path>] [--upstream <url>] [--cooldown-minutes <n>]";
|
|
1966
|
+
function parseGatewayCommand(args) {
|
|
1967
|
+
const [sub, ...rest] = args;
|
|
1968
|
+
if (sub !== "serve")
|
|
1969
|
+
throw new Error(GATEWAY_SERVE_USAGE);
|
|
1970
|
+
let keysDir;
|
|
1971
|
+
let host;
|
|
1972
|
+
let port;
|
|
1973
|
+
let statePath;
|
|
1974
|
+
let upstream;
|
|
1975
|
+
let cooldownMinutes;
|
|
1976
|
+
for (let i = 0; i < rest.length; i += 1) {
|
|
1977
|
+
const flag = rest[i];
|
|
1978
|
+
const value = rest[i + 1];
|
|
1979
|
+
if (!value || value.startsWith("--"))
|
|
1980
|
+
throw new Error(GATEWAY_SERVE_USAGE);
|
|
1981
|
+
i += 1;
|
|
1982
|
+
if (flag === "--keys-dir")
|
|
1983
|
+
keysDir = value;
|
|
1984
|
+
else if (flag === "--host")
|
|
1985
|
+
host = value;
|
|
1986
|
+
else if (flag === "--state")
|
|
1987
|
+
statePath = value;
|
|
1988
|
+
else if (flag === "--upstream") {
|
|
1989
|
+
if (!/^https?:\/\//.test(value))
|
|
1990
|
+
throw new Error("--upstream must be an http(s) URL");
|
|
1991
|
+
upstream = value;
|
|
1992
|
+
}
|
|
1993
|
+
else if (flag === "--port") {
|
|
1994
|
+
const parsed = Number(value);
|
|
1995
|
+
if (!Number.isInteger(parsed) || parsed < 0 || parsed > 65535)
|
|
1996
|
+
throw new Error("Gateway port must be 0-65535");
|
|
1997
|
+
port = parsed;
|
|
1998
|
+
}
|
|
1999
|
+
else if (flag === "--cooldown-minutes") {
|
|
2000
|
+
const parsed = Number(value);
|
|
2001
|
+
if (!Number.isFinite(parsed) || parsed <= 0)
|
|
2002
|
+
throw new Error("--cooldown-minutes must be a positive number");
|
|
2003
|
+
cooldownMinutes = parsed;
|
|
2004
|
+
}
|
|
2005
|
+
else
|
|
2006
|
+
throw new Error(GATEWAY_SERVE_USAGE);
|
|
2007
|
+
}
|
|
2008
|
+
if (!keysDir)
|
|
2009
|
+
throw new Error(GATEWAY_SERVE_USAGE);
|
|
2010
|
+
return {
|
|
2011
|
+
kind: "gateway.serve",
|
|
2012
|
+
keysDir,
|
|
2013
|
+
...(host ? { host } : {}),
|
|
2014
|
+
...(port !== undefined ? { port } : {}),
|
|
2015
|
+
...(statePath ? { statePath } : {}),
|
|
2016
|
+
...(upstream ? { upstream } : {}),
|
|
2017
|
+
...(cooldownMinutes !== undefined ? { cooldownMinutes } : {}),
|
|
2018
|
+
};
|
|
2019
|
+
}
|
|
1939
2020
|
function parseConfigCommand(args) {
|
|
1940
2021
|
const { agent, rest: afterAgent } = extractAgentFlag(args);
|
|
1941
2022
|
const { facing, rest: cleaned } = extractFacingFlag(afterAgent);
|
|
@@ -3118,6 +3199,8 @@ function parseOuroCommand(args) {
|
|
|
3118
3199
|
return parseFriendCommand(args.slice(1));
|
|
3119
3200
|
if (head === "a2a")
|
|
3120
3201
|
return parseA2ACommand(args.slice(1));
|
|
3202
|
+
if (head === "gateway")
|
|
3203
|
+
return parseGatewayCommand(args.slice(1));
|
|
3121
3204
|
if (head === "config")
|
|
3122
3205
|
return parseConfigCommand(args.slice(1));
|
|
3123
3206
|
if (head === "mcp")
|
package/dist/heart/identity.js
CHANGED
|
@@ -69,7 +69,7 @@ var autonomy_budget_2 = require("./autonomy-budget");
|
|
|
69
69
|
Object.defineProperty(exports, "normalizeHabitPaidTurnsPerDay", { enumerable: true, get: function () { return autonomy_budget_2.normalizeHabitPaidTurnsPerDay; } });
|
|
70
70
|
/** Single source of truth for per-provider credential field names, env var mappings, and prompt labels. */
|
|
71
71
|
exports.PROVIDER_CREDENTIALS = {
|
|
72
|
-
anthropic: { required: ["setupToken"], envVars: { ANTHROPIC_API_KEY: "setupToken" }, promptLabels: { setupToken: "Anthropic setup-token" } },
|
|
72
|
+
anthropic: { required: ["setupToken"], alternativeRequired: [["apiKey"]], envVars: { ANTHROPIC_API_KEY: "setupToken" }, promptLabels: { setupToken: "Anthropic setup-token" } },
|
|
73
73
|
"openai-codex": { required: ["oauthAccessToken"], envVars: { OPENAI_API_KEY: "oauthAccessToken" }, promptLabels: { oauthAccessToken: "OpenAI Codex OAuth token" } },
|
|
74
74
|
azure: { required: ["apiKey", "endpoint", "deployment"], envVars: { AZURE_OPENAI_API_KEY: "apiKey", AZURE_OPENAI_KEY: "apiKey", AZURE_OPENAI_ENDPOINT: "endpoint", AZURE_OPENAI_DEPLOYMENT: "deployment" }, promptLabels: { apiKey: "Azure API key", endpoint: "Azure endpoint", deployment: "Azure deployment" } },
|
|
75
75
|
minimax: { required: ["apiKey"], envVars: { MINIMAX_API_KEY: "apiKey" }, promptLabels: { apiKey: "MiniMax API key" } },
|
|
@@ -59,8 +59,8 @@ const VAULT_ITEM_PREFIX = "providers/";
|
|
|
59
59
|
exports.PROVIDER_CREDENTIAL_POOL_NOT_LOADED_ERROR = "provider credentials have not been loaded from vault";
|
|
60
60
|
const PROVIDER_FIELD_SPLITS = {
|
|
61
61
|
anthropic: {
|
|
62
|
-
credentials: ["setupToken", "refreshToken", "expiresAt"],
|
|
63
|
-
config: [],
|
|
62
|
+
credentials: ["setupToken", "refreshToken", "expiresAt", "apiKey"],
|
|
63
|
+
config: ["baseUrl"],
|
|
64
64
|
},
|
|
65
65
|
"openai-codex": {
|
|
66
66
|
credentials: ["oauthAccessToken", "refreshToken", "expiresAt"],
|
|
@@ -157,7 +157,8 @@ function hasEmptyCredentials(provider, config) {
|
|
|
157
157
|
if (hasManagedIdentity)
|
|
158
158
|
return false;
|
|
159
159
|
}
|
|
160
|
-
|
|
160
|
+
const requirements = identity_1.PROVIDER_CREDENTIALS[provider];
|
|
161
|
+
return ![requirements.required, ...(requirements.alternativeRequired ?? [])].some((fields) => fields.every((key) => !!record[key]));
|
|
161
162
|
}
|
|
162
163
|
function createProviderRuntimeForConfig(provider, config, options = {}) {
|
|
163
164
|
// Use the same provider defaults as auth switch and hatch so verification
|
|
@@ -225,7 +225,16 @@ function mergeAnthropicToolArguments(current, partial) {
|
|
|
225
225
|
}
|
|
226
226
|
return current + partial;
|
|
227
227
|
}
|
|
228
|
+
// An org whose prepaid API credits are used up gets HTTP 400 (or 402) with this phrase. That is a
|
|
229
|
+
// usage limit, not a malformed request, so failover tells the human the right thing.
|
|
230
|
+
const ANTHROPIC_CREDIT_LOW_PHRASE = "credit balance is too low";
|
|
231
|
+
function isAnthropicCreditExhausted(error) {
|
|
232
|
+
const status = error.status;
|
|
233
|
+
return (status === 400 || status === 402) && error.message.toLowerCase().includes(ANTHROPIC_CREDIT_LOW_PHRASE);
|
|
234
|
+
}
|
|
228
235
|
function classifyAnthropicError(error) {
|
|
236
|
+
if (isAnthropicCreditExhausted(error))
|
|
237
|
+
return "usage-limit";
|
|
229
238
|
return (0, error_classification_1.classifyHttpError)(error, {
|
|
230
239
|
isAuthFailure: isAnthropicAuthFailure,
|
|
231
240
|
isServerError: (e) => e.status === 529,
|
|
@@ -236,9 +245,10 @@ function isAnthropicAuthFailure(error) {
|
|
|
236
245
|
return (lower.includes("oauth authentication") ||
|
|
237
246
|
lower.includes("authentication failed") ||
|
|
238
247
|
lower.includes("unauthorized") ||
|
|
239
|
-
lower.includes("invalid api key")
|
|
248
|
+
lower.includes("invalid api key") ||
|
|
249
|
+
lower.includes("invalid x-api-key"));
|
|
240
250
|
}
|
|
241
|
-
async function streamAnthropicMessages(client, model, request) {
|
|
251
|
+
async function streamAnthropicMessages(client, model, request, options = { claudeCodePreamble: true }) {
|
|
242
252
|
const { system, messages } = toAnthropicMessages(request.messages);
|
|
243
253
|
const additionalSystemParts = request.messages
|
|
244
254
|
.filter((message) => message.role === "system")
|
|
@@ -260,13 +270,14 @@ async function streamAnthropicMessages(client, model, request) {
|
|
|
260
270
|
// prompt when using OAuth setup tokens (sk-ant-oat01). Without it, Opus/Sonnet
|
|
261
271
|
// 4.6 requests are rejected with 400. This is the API's validation that the
|
|
262
272
|
// token is being used by a Claude Code client.
|
|
263
|
-
|
|
273
|
+
// API-key mode (a plain API key, for example through a credit gateway) needs no such block.
|
|
274
|
+
const preambleText = options.claudeCodePreamble ? "You are Claude Code, Anthropic's official CLI for Claude." : "";
|
|
264
275
|
if (request.systemPrompt) {
|
|
265
276
|
// Structured SystemPrompt: merge preamble + stable prefix into one cached block,
|
|
266
277
|
// volatile suffix as a separate uncached block.
|
|
267
278
|
const stableBlock = {
|
|
268
279
|
type: "text",
|
|
269
|
-
text: preambleText + "\n\n" + request.systemPrompt.stable,
|
|
280
|
+
text: preambleText ? preambleText + "\n\n" + request.systemPrompt.stable : request.systemPrompt.stable,
|
|
270
281
|
cache_control: { type: "ephemeral" },
|
|
271
282
|
};
|
|
272
283
|
params.system = [
|
|
@@ -279,9 +290,11 @@ async function streamAnthropicMessages(client, model, request) {
|
|
|
279
290
|
}
|
|
280
291
|
else if (system) {
|
|
281
292
|
// Fallback: no structured prompt, extract from messages (legacy path)
|
|
282
|
-
params.system =
|
|
293
|
+
params.system = preambleText
|
|
294
|
+
? [{ type: "text", text: preambleText }, { type: "text", text: system }]
|
|
295
|
+
: [{ type: "text", text: system }];
|
|
283
296
|
}
|
|
284
|
-
else {
|
|
297
|
+
else if (preambleText) {
|
|
285
298
|
params.system = [{ type: "text", text: preambleText }];
|
|
286
299
|
}
|
|
287
300
|
if (anthropicTools.length > 0)
|
|
@@ -441,6 +454,48 @@ async function streamAnthropicMessages(client, model, request) {
|
|
|
441
454
|
...(settleFinalization ? { settleFinalization } : {}),
|
|
442
455
|
};
|
|
443
456
|
}
|
|
457
|
+
const ANTHROPIC_PING_MODEL = "claude-haiku-4-5-20251001";
|
|
458
|
+
/**
|
|
459
|
+
* API-key mode: a plain Anthropic API key, optionally against another base URL such as a credit
|
|
460
|
+
* gateway. No Claude Code identity headers, no Claude Code system preamble and no token refresh;
|
|
461
|
+
* prompt caching, thinking, tools and streaming work as in setup-token mode.
|
|
462
|
+
*/
|
|
463
|
+
function createAnthropicApiKeyRuntime(model, apiKey, baseUrl) {
|
|
464
|
+
const modelCaps = (0, model_capabilities_1.getModelCapabilities)(model);
|
|
465
|
+
const capabilities = new Set();
|
|
466
|
+
if (modelCaps.reasoningEffort)
|
|
467
|
+
capabilities.add("reasoning-effort");
|
|
468
|
+
// authToken: null keeps the SDK from also sending an ANTHROPIC_AUTH_TOKEN from the environment.
|
|
469
|
+
const client = new sdk_1.default({ apiKey, authToken: null, baseURL: baseUrl, maxRetries: 0 });
|
|
470
|
+
(0, runtime_1.emitNervesEvent)({
|
|
471
|
+
component: "engine",
|
|
472
|
+
event: "engine.anthropic_api_key_mode",
|
|
473
|
+
message: "anthropic provider using API-key mode",
|
|
474
|
+
meta: { provider: "anthropic", customBaseUrl: baseUrl !== undefined },
|
|
475
|
+
});
|
|
476
|
+
return {
|
|
477
|
+
id: "anthropic",
|
|
478
|
+
model,
|
|
479
|
+
client,
|
|
480
|
+
capabilities,
|
|
481
|
+
supportedReasoningEfforts: modelCaps.reasoningEffort,
|
|
482
|
+
resetTurnState(_messages) {
|
|
483
|
+
// Anthropic request payload is derived from canonical messages each turn.
|
|
484
|
+
},
|
|
485
|
+
appendToolOutput(_callId, _output) {
|
|
486
|
+
// Anthropic uses canonical messages for tool_result tracking.
|
|
487
|
+
},
|
|
488
|
+
streamTurn(request) {
|
|
489
|
+
return streamAnthropicMessages(client, model, request, { claudeCodePreamble: false });
|
|
490
|
+
},
|
|
491
|
+
async ping(signal) {
|
|
492
|
+
await client.messages.create({ model: ANTHROPIC_PING_MODEL, max_tokens: 1, messages: [{ role: "user", content: "ping" }] }, { signal });
|
|
493
|
+
},
|
|
494
|
+
classifyError(error) {
|
|
495
|
+
return classifyAnthropicError(error);
|
|
496
|
+
},
|
|
497
|
+
};
|
|
498
|
+
}
|
|
444
499
|
function createAnthropicProviderRuntime(model, anthropicConfig = (0, config_1.getAnthropicConfig)()) {
|
|
445
500
|
(0, runtime_1.emitNervesEvent)({
|
|
446
501
|
component: "engine",
|
|
@@ -448,6 +503,9 @@ function createAnthropicProviderRuntime(model, anthropicConfig = (0, config_1.ge
|
|
|
448
503
|
message: "anthropic provider init",
|
|
449
504
|
meta: { provider: "anthropic" },
|
|
450
505
|
});
|
|
506
|
+
const apiKey = anthropicConfig.apiKey?.trim();
|
|
507
|
+
if (apiKey)
|
|
508
|
+
return createAnthropicApiKeyRuntime(model, apiKey, anthropicConfig.baseUrl?.trim() || undefined);
|
|
451
509
|
if (!anthropicConfig.setupToken) {
|
|
452
510
|
throw new Error(getAnthropicReauthGuidance("provider 'anthropic' is selected but anthropic.setupToken is missing in the agent vault."));
|
|
453
511
|
}
|
|
@@ -509,7 +567,7 @@ function createAnthropicProviderRuntime(model, anthropicConfig = (0, config_1.ge
|
|
|
509
567
|
/* v8 ignore start -- ping: tested via provider-ping.test.ts @preserve */
|
|
510
568
|
async ping(signal) {
|
|
511
569
|
const freshClient = await ensureClient();
|
|
512
|
-
await freshClient.messages.create({ model:
|
|
570
|
+
await freshClient.messages.create({ model: ANTHROPIC_PING_MODEL, max_tokens: 1, messages: [{ role: "user", content: "ping" }] }, { signal, headers: { "anthropic-beta": "claude-code-20250219,oauth-2025-04-20" } });
|
|
513
571
|
},
|
|
514
572
|
/* v8 ignore stop */
|
|
515
573
|
/* v8 ignore next 3 -- delegation: classification logic tested via classifyAnthropicError @preserve */
|
|
@@ -108,7 +108,8 @@ function isProviderCredentialRecord(value) {
|
|
|
108
108
|
const provider = value.provider;
|
|
109
109
|
const canonicalCredentials = (0, provider_credentials_1.splitProviderCredentialFields)(provider, credentials).credentials;
|
|
110
110
|
const canonicalConfig = (0, provider_credentials_1.splitProviderCredentialFields)(provider, config).config;
|
|
111
|
-
|
|
111
|
+
const requirements = identity_1.PROVIDER_CREDENTIALS[provider];
|
|
112
|
+
if (![requirements.required, ...(requirements.alternativeRequired ?? [])].some((fields) => fields.every((field) => (isPresentCredentialValue(canonicalCredentials[field]) || isPresentCredentialValue(canonicalConfig[field])))))
|
|
112
113
|
return false;
|
|
113
114
|
const provenance = value.provenance;
|
|
114
115
|
if (!isRecord(provenance))
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ouro.bot/cli",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.956",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@ouro.bot/cli",
|
|
9
|
-
"version": "0.1.0-alpha.
|
|
9
|
+
"version": "0.1.0-alpha.956",
|
|
10
10
|
"dependencies": {
|
|
11
11
|
"@anthropic-ai/sdk": "^0.78.0",
|
|
12
12
|
"@azure/identity": "^4.13.0",
|