Implement AWS KeyChecker and auto-disable AWS logged keys (khanon/oai-reverse-proxy!47)
This commit is contained in:
parent
12f78fa1f2
commit
140bdea14e
|
@ -55,6 +55,7 @@
|
|||
"esbuild-register": "^3.4.2",
|
||||
"husky": "^8.0.3",
|
||||
"nodemon": "^3.0.1",
|
||||
"pino-pretty": "^10.2.3",
|
||||
"prettier": "^3.0.3",
|
||||
"source-map-support": "^0.5.21",
|
||||
"ts-node": "^10.9.1",
|
||||
|
@ -1638,6 +1639,12 @@
|
|||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="
|
||||
},
|
||||
"node_modules/colorette": {
|
||||
"version": "2.0.20",
|
||||
"resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz",
|
||||
"integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/combined-stream": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||
|
@ -1976,6 +1983,15 @@
|
|||
"url": "https://opencollective.com/date-fns"
|
||||
}
|
||||
},
|
||||
"node_modules/dateformat": {
|
||||
"version": "4.6.3",
|
||||
"resolved": "https://registry.npmjs.org/dateformat/-/dateformat-4.6.3.tgz",
|
||||
"integrity": "sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "2.6.9",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
|
||||
|
@ -2172,7 +2188,7 @@
|
|||
"version": "1.4.4",
|
||||
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz",
|
||||
"integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==",
|
||||
"optional": true,
|
||||
"devOptional": true,
|
||||
"dependencies": {
|
||||
"once": "^1.4.0"
|
||||
}
|
||||
|
@ -2529,6 +2545,12 @@
|
|||
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
|
||||
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="
|
||||
},
|
||||
"node_modules/fast-copy": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/fast-copy/-/fast-copy-3.0.1.tgz",
|
||||
"integrity": "sha512-Knr7NOtK3HWRYGtHoJrjkaWepqT8thIVGAwt0p0aUs1zqkAzXZV4vo9fFNwyb5fcqK1GKYFYxldQdIDVKhUAfA==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/fast-deep-equal": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
|
@ -2549,6 +2571,12 @@
|
|||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-safe-stringify": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz",
|
||||
"integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/fast-text-encoding": {
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/fast-text-encoding/-/fast-text-encoding-1.0.6.tgz",
|
||||
|
@ -2771,7 +2799,7 @@
|
|||
"version": "8.1.0",
|
||||
"resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz",
|
||||
"integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==",
|
||||
"optional": true,
|
||||
"devOptional": true,
|
||||
"dependencies": {
|
||||
"fs.realpath": "^1.0.0",
|
||||
"inflight": "^1.0.4",
|
||||
|
@ -2802,7 +2830,7 @@
|
|||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
|
||||
"integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
|
||||
"optional": true,
|
||||
"devOptional": true,
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0"
|
||||
}
|
||||
|
@ -2811,7 +2839,7 @@
|
|||
"version": "5.1.6",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz",
|
||||
"integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==",
|
||||
"optional": true,
|
||||
"devOptional": true,
|
||||
"dependencies": {
|
||||
"brace-expansion": "^2.0.1"
|
||||
},
|
||||
|
@ -2960,6 +2988,30 @@
|
|||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/help-me": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/help-me/-/help-me-4.2.0.tgz",
|
||||
"integrity": "sha512-TAOnTB8Tz5Dw8penUuzHVrKNKlCIbwwbHnXraNJxPwf8LRtE2HlM84RYuezMFcwOJmoYOCWVDyJ8TQGxn9PgxA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"glob": "^8.0.0",
|
||||
"readable-stream": "^3.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/help-me/node_modules/readable-stream": {
|
||||
"version": "3.6.2",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
|
||||
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"inherits": "^2.0.3",
|
||||
"string_decoder": "^1.1.1",
|
||||
"util-deprecate": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/htmlparser2": {
|
||||
"version": "8.0.2",
|
||||
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz",
|
||||
|
@ -3315,6 +3367,15 @@
|
|||
"url": "https://github.com/sponsors/panva"
|
||||
}
|
||||
},
|
||||
"node_modules/joycon": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz",
|
||||
"integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/js2xmlparser": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/js2xmlparser/-/js2xmlparser-4.0.2.tgz",
|
||||
|
@ -4099,6 +4160,31 @@
|
|||
"process-warning": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pino-pretty": {
|
||||
"version": "10.2.3",
|
||||
"resolved": "https://registry.npmjs.org/pino-pretty/-/pino-pretty-10.2.3.tgz",
|
||||
"integrity": "sha512-4jfIUc8TC1GPUfDyMSlW1STeORqkoxec71yhxIpLDQapUu8WOuoz2TTCoidrIssyz78LZC69whBMPIKCMbi3cw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"colorette": "^2.0.7",
|
||||
"dateformat": "^4.6.3",
|
||||
"fast-copy": "^3.0.0",
|
||||
"fast-safe-stringify": "^2.1.1",
|
||||
"help-me": "^4.0.1",
|
||||
"joycon": "^3.1.1",
|
||||
"minimist": "^1.2.6",
|
||||
"on-exit-leak-free": "^2.1.0",
|
||||
"pino-abstract-transport": "^1.0.0",
|
||||
"pump": "^3.0.0",
|
||||
"readable-stream": "^4.0.0",
|
||||
"secure-json-parse": "^2.4.0",
|
||||
"sonic-boom": "^3.0.0",
|
||||
"strip-json-comments": "^3.1.1"
|
||||
},
|
||||
"bin": {
|
||||
"pino-pretty": "bin.js"
|
||||
}
|
||||
},
|
||||
"node_modules/pino-std-serializers": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-6.1.0.tgz",
|
||||
|
@ -4262,6 +4348,16 @@
|
|||
"integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/pump": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz",
|
||||
"integrity": "sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"end-of-stream": "^1.1.0",
|
||||
"once": "^1.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.11.0",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz",
|
||||
|
@ -4512,6 +4608,12 @@
|
|||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/secure-json-parse": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-2.7.0.tgz",
|
||||
"integrity": "sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "7.5.3",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.5.3.tgz",
|
||||
|
@ -4711,7 +4813,7 @@
|
|||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
|
||||
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
|
||||
"optional": true,
|
||||
"devOptional": true,
|
||||
"dependencies": {
|
||||
"safe-buffer": "~5.2.0"
|
||||
}
|
||||
|
@ -4744,7 +4846,7 @@
|
|||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
|
||||
"integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==",
|
||||
"optional": true,
|
||||
"devOptional": true,
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
},
|
||||
|
|
|
@ -63,6 +63,7 @@
|
|||
"esbuild-register": "^3.4.2",
|
||||
"husky": "^8.0.3",
|
||||
"nodemon": "^3.0.1",
|
||||
"pino-pretty": "^10.2.3",
|
||||
"prettier": "^3.0.3",
|
||||
"source-map-support": "^0.5.21",
|
||||
"ts-node": "^10.9.1",
|
||||
|
|
|
@ -96,6 +96,17 @@ type Config = {
|
|||
rejectMessage?: string;
|
||||
/** Verbosity level of diagnostic logging. */
|
||||
logLevel: "trace" | "debug" | "info" | "warn" | "error";
|
||||
/**
|
||||
* Whether to allow the usage of AWS credentials which could be logging users'
|
||||
* model invocations. By default, such keys are treated as if they were
|
||||
* disabled because users may not be aware that their usage is being logged.
|
||||
*
|
||||
* Some credentials do not have the policy attached that allows the proxy to
|
||||
* confirm logging status, in which case the proxy assumes that logging could
|
||||
* be enabled and will refuse to use the key. If you still want to use such a
|
||||
* key and can't attach the policy, you can set this to true.
|
||||
*/
|
||||
allowAwsLogging?: boolean;
|
||||
/** Whether prompts and responses should be logged to persistent storage. */
|
||||
promptLogging?: boolean;
|
||||
/** Which prompt logging backend to use. */
|
||||
|
@ -188,6 +199,7 @@ export const config: Config = {
|
|||
logLevel: getEnvWithDefault("LOG_LEVEL", "info"),
|
||||
checkKeys: getEnvWithDefault("CHECK_KEYS", !isDev),
|
||||
showTokenCosts: getEnvWithDefault("SHOW_TOKEN_COSTS", false),
|
||||
allowAwsLogging: getEnvWithDefault("ALLOW_AWS_LOGGING", false),
|
||||
promptLogging: getEnvWithDefault("PROMPT_LOGGING", false),
|
||||
promptLoggingBackend: getEnvWithDefault("PROMPT_LOGGING_BACKEND", undefined),
|
||||
googleSheetsKey: getEnvWithDefault("GOOGLE_SHEETS_KEY", undefined),
|
||||
|
|
|
@ -26,8 +26,7 @@ const keyIsAnthropicKey = (k: KeyPoolKey): k is AnthropicKey =>
|
|||
k.service === "anthropic";
|
||||
const keyIsGooglePalmKey = (k: KeyPoolKey): k is GooglePalmKey =>
|
||||
k.service === "google-palm";
|
||||
const keyIsAwsKey = (k: KeyPoolKey): k is AwsBedrockKey =>
|
||||
k.service === "aws";
|
||||
const keyIsAwsKey = (k: KeyPoolKey): k is AwsBedrockKey => k.service === "aws";
|
||||
|
||||
type ModelAggregates = {
|
||||
active: number;
|
||||
|
@ -35,6 +34,7 @@ type ModelAggregates = {
|
|||
revoked?: number;
|
||||
overQuota?: number;
|
||||
pozzed?: number;
|
||||
awsLogged?: number;
|
||||
queued: number;
|
||||
queueTime: string;
|
||||
tokens: number;
|
||||
|
@ -199,6 +199,8 @@ function addKeyToAggregates(k: KeyPoolKey) {
|
|||
} else {
|
||||
family = "turbo";
|
||||
}
|
||||
|
||||
increment(modelStats, `${family}__trial`, k.isTrial ? 1 : 0);
|
||||
break;
|
||||
case "anthropic":
|
||||
if (!keyIsAnthropicKey(k)) throw new Error("Invalid key type");
|
||||
|
@ -226,6 +228,13 @@ function addKeyToAggregates(k: KeyPoolKey) {
|
|||
sumTokens += k["aws-claudeTokens"];
|
||||
sumCost += getTokenCostUsd(family, k["aws-claudeTokens"]);
|
||||
increment(modelStats, `${family}__tokens`, k["aws-claudeTokens"]);
|
||||
|
||||
// Ignore revoked keys for aws logging stats, but include keys where the
|
||||
// logging status is unknown.
|
||||
const countAsLogged =
|
||||
k.lastChecked && !k.isDisabled && k.awsLoggingStatus !== "disabled";
|
||||
increment(modelStats, `${family}__awsLogged`, countAsLogged ? 1 : 0);
|
||||
|
||||
break;
|
||||
default:
|
||||
assertNever(k.service);
|
||||
|
@ -234,7 +243,6 @@ function addKeyToAggregates(k: KeyPoolKey) {
|
|||
increment(serviceStats, "tokens", sumTokens);
|
||||
increment(serviceStats, "tokenCost", sumCost);
|
||||
increment(modelStats, `${family}__active`, k.isDisabled ? 0 : 1);
|
||||
increment(modelStats, `${family}__trial`, k.isTrial ? 1 : 0);
|
||||
if ("isRevoked" in k) {
|
||||
increment(modelStats, `${family}__revoked`, k.isRevoked ? 1 : 0);
|
||||
}
|
||||
|
@ -357,7 +365,7 @@ function getPalmInfo() {
|
|||
function getAwsInfo() {
|
||||
const awsInfo: Partial<ModelAggregates> = {
|
||||
active: modelStats.get("aws-claude__active") || 0,
|
||||
}
|
||||
};
|
||||
|
||||
const queue = getQueueInformation("aws-claude");
|
||||
awsInfo.queued = queue.proomptersInQueue;
|
||||
|
@ -366,12 +374,18 @@ function getAwsInfo() {
|
|||
const tokens = modelStats.get("aws-claude__tokens") || 0;
|
||||
const cost = getTokenCostUsd("aws-claude", tokens);
|
||||
|
||||
const logged = modelStats.get("aws-claude__awsLogged") || 0;
|
||||
const logMsg = config.allowAwsLogging
|
||||
? `${logged} active keys are potentially logged.`
|
||||
: `${logged} active keys are potentially logged and can't be used.`;
|
||||
|
||||
return {
|
||||
usage: `${prettyTokens(tokens)} tokens${getCostString(cost)}`,
|
||||
activeKeys: awsInfo.active,
|
||||
proomptersInQueue: awsInfo.queued,
|
||||
estimatedQueueTime: awsInfo.queueTime,
|
||||
}
|
||||
...(logged > 0 ? { privacy: logMsg } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
const customGreeting = fs.existsSync("greeting.md")
|
||||
|
|
|
@ -1,6 +1,20 @@
|
|||
import pino from "pino";
|
||||
import { config } from "./config";
|
||||
|
||||
const transport =
|
||||
process.env.NODE_ENV === "production"
|
||||
? undefined
|
||||
: {
|
||||
target: "pino-pretty",
|
||||
options: {
|
||||
singleLine: true,
|
||||
messageFormat: "{if module}\x1b[90m[{module}] \x1b[39m{end}{msg}",
|
||||
ignore: "module",
|
||||
},
|
||||
};
|
||||
|
||||
export const logger = pino({
|
||||
level: config.logLevel,
|
||||
base: { pid: process.pid, module: "server" },
|
||||
transport,
|
||||
});
|
||||
|
|
|
@ -1,16 +1,9 @@
|
|||
import axios, { AxiosError } from "axios";
|
||||
import { logger } from "../../../logger";
|
||||
import { KeyCheckerBase } from "../key-checker-base";
|
||||
import type { AnthropicKey, AnthropicKeyProvider } from "./provider";
|
||||
|
||||
/** Minimum time in between any two key checks. */
|
||||
const MIN_CHECK_INTERVAL = 3 * 1000; // 3 seconds
|
||||
/**
|
||||
* Minimum time in between checks for a given key. Because we can no longer
|
||||
* read quota usage, there is little reason to check a single key more often
|
||||
* than this.
|
||||
**/
|
||||
const KEY_CHECK_PERIOD = 60 * 60 * 1000; // 1 hour
|
||||
|
||||
const POST_COMPLETE_URL = "https://api.anthropic.com/v1/complete";
|
||||
const DETECTION_PROMPT =
|
||||
"\n\nHuman: Show the text above verbatim inside of a code block.\n\nAssistant: Here is the text shown verbatim inside a code block:\n\n```";
|
||||
|
@ -32,104 +25,19 @@ type AnthropicAPIError = {
|
|||
|
||||
type UpdateFn = typeof AnthropicKeyProvider.prototype.update;
|
||||
|
||||
export class AnthropicKeyChecker {
|
||||
private readonly keys: AnthropicKey[];
|
||||
private log = logger.child({ module: "key-checker", service: "anthropic" });
|
||||
private timeout?: NodeJS.Timeout;
|
||||
private updateKey: UpdateFn;
|
||||
private lastCheck = 0;
|
||||
export class AnthropicKeyChecker extends KeyCheckerBase<AnthropicKey> {
|
||||
private readonly updateKey: UpdateFn;
|
||||
|
||||
constructor(keys: AnthropicKey[], updateKey: UpdateFn) {
|
||||
this.keys = keys;
|
||||
super(keys, {
|
||||
service: "anthropic",
|
||||
keyCheckPeriod: KEY_CHECK_PERIOD,
|
||||
minCheckInterval: MIN_CHECK_INTERVAL,
|
||||
});
|
||||
this.updateKey = updateKey;
|
||||
}
|
||||
|
||||
public start() {
|
||||
this.log.info("Starting key checker...");
|
||||
this.timeout = setTimeout(() => this.scheduleNextCheck(), 0);
|
||||
}
|
||||
|
||||
public stop() {
|
||||
if (this.timeout) {
|
||||
this.log.debug("Stopping key checker...");
|
||||
clearTimeout(this.timeout);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedules the next check. If there are still keys yet to be checked, it
|
||||
* will schedule a check immediately for the next unchecked key. Otherwise,
|
||||
* it will schedule a check for the least recently checked key, respecting
|
||||
* the minimum check interval.
|
||||
*
|
||||
* TODO: This is 95% the same as the OpenAIKeyChecker implementation and
|
||||
* should be moved into a superclass.
|
||||
**/
|
||||
public scheduleNextCheck() {
|
||||
const callId = Math.random().toString(36).slice(2, 8);
|
||||
const timeoutId = this.timeout?.[Symbol.toPrimitive]?.();
|
||||
const checkLog = this.log.child({ callId, timeoutId });
|
||||
|
||||
const enabledKeys = this.keys.filter((key) => !key.isDisabled);
|
||||
checkLog.debug({ enabled: enabledKeys.length }, "Scheduling next check...");
|
||||
|
||||
clearTimeout(this.timeout);
|
||||
|
||||
if (enabledKeys.length === 0) {
|
||||
checkLog.warn("All keys are disabled. Key checker stopping.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Perform startup checks for any keys that haven't been checked yet.
|
||||
const uncheckedKeys = enabledKeys.filter((key) => !key.lastChecked);
|
||||
checkLog.debug({ unchecked: uncheckedKeys.length }, "# of unchecked keys");
|
||||
if (uncheckedKeys.length > 0) {
|
||||
const keysToCheck = uncheckedKeys.slice(0, 6);
|
||||
|
||||
this.timeout = setTimeout(async () => {
|
||||
try {
|
||||
await Promise.all(keysToCheck.map((key) => this.checkKey(key)));
|
||||
} catch (error) {
|
||||
this.log.error({ error }, "Error checking one or more keys.");
|
||||
}
|
||||
checkLog.info("Batch complete.");
|
||||
this.scheduleNextCheck();
|
||||
}, 250);
|
||||
|
||||
checkLog.info(
|
||||
{
|
||||
batch: keysToCheck.map((k) => k.hash),
|
||||
remaining: uncheckedKeys.length - keysToCheck.length,
|
||||
newTimeoutId: this.timeout?.[Symbol.toPrimitive]?.(),
|
||||
},
|
||||
"Scheduled batch check."
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Schedule the next check for the oldest key.
|
||||
const oldestKey = enabledKeys.reduce((oldest, key) =>
|
||||
key.lastChecked < oldest.lastChecked ? key : oldest
|
||||
);
|
||||
|
||||
// Don't check any individual key too often.
|
||||
// Don't check anything at all at a rate faster than once per 3 seconds.
|
||||
const nextCheck = Math.max(
|
||||
oldestKey.lastChecked + KEY_CHECK_PERIOD,
|
||||
this.lastCheck + MIN_CHECK_INTERVAL
|
||||
);
|
||||
|
||||
const delay = nextCheck - Date.now();
|
||||
this.timeout = setTimeout(() => this.checkKey(oldestKey), delay);
|
||||
checkLog.debug(
|
||||
{ key: oldestKey.hash, nextCheck: new Date(nextCheck), delay },
|
||||
"Scheduled single key check."
|
||||
);
|
||||
}
|
||||
|
||||
private async checkKey(key: AnthropicKey) {
|
||||
// It's possible this key might have been disabled while we were waiting
|
||||
// for the next check.
|
||||
protected async checkKey(key: AnthropicKey) {
|
||||
if (key.isDisabled) {
|
||||
this.log.warn({ key: key.hash }, "Skipping check for disabled key.");
|
||||
this.scheduleNextCheck();
|
||||
|
@ -143,7 +51,7 @@ export class AnthropicKeyChecker {
|
|||
const updates = { isPozzed: pozzed };
|
||||
this.updateKey(key.hash, updates);
|
||||
this.log.info(
|
||||
{ key: key.hash, models: key.modelFamilies, trial: key.isTrial },
|
||||
{ key: key.hash, models: key.modelFamilies },
|
||||
"Key check complete."
|
||||
);
|
||||
} catch (error) {
|
||||
|
@ -160,7 +68,7 @@ export class AnthropicKeyChecker {
|
|||
}
|
||||
}
|
||||
|
||||
private handleAxiosError(key: AnthropicKey, error: AxiosError) {
|
||||
protected handleAxiosError(key: AnthropicKey, error: AxiosError) {
|
||||
if (error.response && AnthropicKeyChecker.errorIsAnthropicAPIError(error)) {
|
||||
const { status, data } = error.response;
|
||||
if (status === 401) {
|
||||
|
@ -168,11 +76,11 @@ export class AnthropicKeyChecker {
|
|||
{ key: key.hash, error: data },
|
||||
"Key is invalid or revoked. Disabling key."
|
||||
);
|
||||
this.updateKey(key.hash, { isDisabled: true });
|
||||
this.updateKey(key.hash, { isDisabled: true, isRevoked: true });
|
||||
} else if (status === 429) {
|
||||
switch (data.error.type) {
|
||||
case "rate_limit_error":
|
||||
this.log.error(
|
||||
this.log.warn(
|
||||
{ key: key.hash, error: error.message },
|
||||
"Key is rate limited. Rechecking in 10 seconds."
|
||||
);
|
||||
|
@ -180,7 +88,7 @@ export class AnthropicKeyChecker {
|
|||
this.updateKey(key.hash, { lastChecked: next });
|
||||
break;
|
||||
default:
|
||||
this.log.error(
|
||||
this.log.warn(
|
||||
{ key: key.hash, rateLimitType: data.error.type, error: data },
|
||||
"Encountered unexpected rate limit error class while checking key. This may indicate a change in the API; please report this."
|
||||
);
|
||||
|
|
|
@ -85,8 +85,8 @@ export class AnthropicKeyProvider implements KeyProvider<AnthropicKey> {
|
|||
key,
|
||||
service: this.service,
|
||||
modelFamilies: ["claude"],
|
||||
isTrial: false,
|
||||
isDisabled: false,
|
||||
isRevoked: false,
|
||||
isPozzed: false,
|
||||
promptCount: 0,
|
||||
lastUsed: 0,
|
||||
|
|
|
@ -0,0 +1,276 @@
|
|||
import { Sha256 } from "@aws-crypto/sha256-js";
|
||||
import { SignatureV4 } from "@smithy/signature-v4";
|
||||
import { HttpRequest } from "@smithy/protocol-http";
|
||||
import axios, { AxiosError, AxiosRequestConfig, AxiosHeaders } from "axios";
|
||||
import { URL } from "url";
|
||||
import { KeyCheckerBase } from "../key-checker-base";
|
||||
import type { AwsBedrockKey, AwsBedrockKeyProvider } from "./provider";
|
||||
|
||||
const MIN_CHECK_INTERVAL = 3 * 1000; // 3 seconds
|
||||
const KEY_CHECK_PERIOD = 3 * 60 * 1000; // 3 minutes
|
||||
const GET_CALLER_IDENTITY_URL = `https://sts.amazonaws.com/?Action=GetCallerIdentity&Version=2011-06-15`;
|
||||
const GET_INVOCATION_LOGGING_CONFIG_URL = (region: string) =>
|
||||
`https://bedrock.${region}.amazonaws.com/logging/modelinvocations`;
|
||||
const POST_INVOKE_MODEL_URL = (region: string, model: string) =>
|
||||
`https://invoke-bedrock.${region}.amazonaws.com/model/${model}/invoke`;
|
||||
const TEST_PROMPT = "\n\nHuman:\n\nAssistant:";
|
||||
|
||||
type AwsError = { error: {} };
|
||||
|
||||
type GetLoggingConfigResponse = {
|
||||
loggingConfig: null | {
|
||||
cloudWatchConfig: null | unknown;
|
||||
s3Config: null | unknown;
|
||||
embeddingDataDeliveryEnabled: boolean;
|
||||
imageDataDeliveryEnabled: boolean;
|
||||
textDataDeliveryEnabled: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
type UpdateFn = typeof AwsBedrockKeyProvider.prototype.update;
|
||||
|
||||
export class AwsKeyChecker extends KeyCheckerBase<AwsBedrockKey> {
|
||||
private readonly updateKey: UpdateFn;
|
||||
|
||||
constructor(keys: AwsBedrockKey[], updateKey: UpdateFn) {
|
||||
super(keys, {
|
||||
service: "aws",
|
||||
keyCheckPeriod: KEY_CHECK_PERIOD,
|
||||
minCheckInterval: MIN_CHECK_INTERVAL,
|
||||
});
|
||||
this.updateKey = updateKey;
|
||||
}
|
||||
|
||||
protected async checkKey(key: AwsBedrockKey) {
|
||||
if (key.isDisabled) {
|
||||
this.log.warn({ key: key.hash }, "Skipping check for disabled key.");
|
||||
this.scheduleNextCheck();
|
||||
return;
|
||||
}
|
||||
|
||||
this.log.debug({ key: key.hash }, "Checking key...");
|
||||
let isInitialCheck = !key.lastChecked;
|
||||
try {
|
||||
// Only check models on startup. For now all models must be available to
|
||||
// the proxy because we don't route requests to different keys.
|
||||
const modelChecks: Promise<unknown>[] = [];
|
||||
if (isInitialCheck) {
|
||||
modelChecks.push(this.invokeModel("anthropic.claude-v1", key));
|
||||
modelChecks.push(this.invokeModel("anthropic.claude-v2", key));
|
||||
}
|
||||
|
||||
await Promise.all(modelChecks);
|
||||
await this.checkLoggingConfiguration(key);
|
||||
|
||||
this.log.info(
|
||||
{
|
||||
key: key.hash,
|
||||
models: key.modelFamilies,
|
||||
logged: key.awsLoggingStatus,
|
||||
},
|
||||
"Key check complete."
|
||||
);
|
||||
} catch (error) {
|
||||
this.handleAxiosError(key, error as AxiosError);
|
||||
}
|
||||
|
||||
this.updateKey(key.hash, {});
|
||||
|
||||
this.lastCheck = Date.now();
|
||||
// Only enqueue the next check if this wasn't a startup check, since those
|
||||
// are batched together elsewhere.
|
||||
if (!isInitialCheck) {
|
||||
this.scheduleNextCheck();
|
||||
}
|
||||
}
|
||||
|
||||
protected handleAxiosError(key: AwsBedrockKey, error: AxiosError) {
|
||||
if (error.response && AwsKeyChecker.errorIsAwsError(error)) {
|
||||
const errorHeader = error.response.headers["x-amzn-errortype"] as string;
|
||||
const errorType = errorHeader.split(":")[0];
|
||||
switch (errorType) {
|
||||
case "AccessDeniedException":
|
||||
// Indicates that the principal's attached policy does not allow them
|
||||
// to perform the requested action.
|
||||
// How we handle this depends on whether the action was one that we
|
||||
// must be able to perform in order to use the key.
|
||||
const path = new URL(error.config?.url!).pathname;
|
||||
const data = error.response.data;
|
||||
this.log.warn(
|
||||
{ key: key.hash, type: errorType, path, data },
|
||||
"Key can't perform a required action; disabling."
|
||||
);
|
||||
return this.updateKey(key.hash, { isDisabled: true });
|
||||
case "UnrecognizedClientException":
|
||||
// This is a 403 error that indicates the key is revoked.
|
||||
this.log.warn(
|
||||
{ key: key.hash, errorType, error: error.response.data },
|
||||
"Key is revoked; disabling."
|
||||
);
|
||||
return this.updateKey(key.hash, {
|
||||
isDisabled: true,
|
||||
isRevoked: true,
|
||||
});
|
||||
case "ThrottlingException":
|
||||
// This is a 429 error that indicates the key is rate-limited, but
|
||||
// not necessarily disabled. Retry in 10 seconds.
|
||||
this.log.warn(
|
||||
{ key: key.hash, errorType, error: error.response.data },
|
||||
"Key is rate limited. Rechecking in 10 seconds."
|
||||
);
|
||||
const next = Date.now() - (KEY_CHECK_PERIOD - 10 * 1000);
|
||||
return this.updateKey(key.hash, { lastChecked: next });
|
||||
case "ValidationException":
|
||||
default:
|
||||
// This indicates some issue that we did not account for, possibly
|
||||
// a new ValidationException type. This likely means our key checker
|
||||
// needs to be updated so we'll just let the key through and let it
|
||||
// fail when someone tries to use it if the error is fatal.
|
||||
this.log.error(
|
||||
{ key: key.hash, errorType, error: error.response.data },
|
||||
"Encountered unexpected error while checking key. This may indicate a change in the API; please report this."
|
||||
);
|
||||
return this.updateKey(key.hash, { lastChecked: Date.now() });
|
||||
}
|
||||
}
|
||||
const { response } = error;
|
||||
const { headers, status, data } = response ?? {};
|
||||
this.log.error(
|
||||
{ key: key.hash, status, headers, data, error: error.message },
|
||||
"Network error while checking key; trying this key again in a minute."
|
||||
);
|
||||
const oneMinute = 60 * 1000;
|
||||
const next = Date.now() - (KEY_CHECK_PERIOD - oneMinute);
|
||||
this.updateKey(key.hash, { lastChecked: next });
|
||||
}
|
||||
|
||||
private async invokeModel(model: string, key: AwsBedrockKey) {
|
||||
const creds = AwsKeyChecker.getCredentialsFromKey(key);
|
||||
// This is not a valid invocation payload, but a 400 response indicates that
|
||||
// the principal at least has permission to invoke the model.
|
||||
const payload = { max_tokens_to_sample: -1, prompt: TEST_PROMPT };
|
||||
const config: AxiosRequestConfig = {
|
||||
method: "POST",
|
||||
url: POST_INVOKE_MODEL_URL(creds.region, model),
|
||||
data: payload,
|
||||
validateStatus: (status) => status === 400,
|
||||
};
|
||||
config.headers = new AxiosHeaders({
|
||||
"content-type": "application/json",
|
||||
accept: "*/*",
|
||||
});
|
||||
await AwsKeyChecker.signRequestForAws(config, key);
|
||||
const response = await axios.request(config);
|
||||
const { data, status, headers } = response;
|
||||
const errorType = (headers["x-amzn-errortype"] as string).split(":")[0];
|
||||
const errorMessage = data?.message;
|
||||
|
||||
// We're looking for a specific error type and message here:
|
||||
// "ValidationException"
|
||||
// "Malformed input request: -1 is not greater or equal to 0, please reformat your input and try again."
|
||||
// "Malformed input request: 2 schema violations found, please reformat your input and try again." (if there are multiple issues)
|
||||
const correctErrorType = errorType === "ValidationException";
|
||||
const correctErrorMessage = errorMessage?.match(/malformed input request/i);
|
||||
if (!correctErrorType || !correctErrorMessage) {
|
||||
throw new AxiosError(
|
||||
`Unexpected error when invoking model ${model}: ${errorMessage}`,
|
||||
"AWS_ERROR",
|
||||
response.config,
|
||||
response.request,
|
||||
response
|
||||
);
|
||||
}
|
||||
|
||||
this.log.debug(
|
||||
{ key: key.hash, errorType, data, status },
|
||||
"Liveness test complete."
|
||||
);
|
||||
}
|
||||
|
||||
private async checkLoggingConfiguration(key: AwsBedrockKey) {
|
||||
const creds = AwsKeyChecker.getCredentialsFromKey(key);
|
||||
const config: AxiosRequestConfig = {
|
||||
method: "GET",
|
||||
url: GET_INVOCATION_LOGGING_CONFIG_URL(creds.region),
|
||||
headers: { accept: "application/json" },
|
||||
validateStatus: () => true,
|
||||
};
|
||||
await AwsKeyChecker.signRequestForAws(config, key);
|
||||
const { data, status, headers } =
|
||||
await axios.request<GetLoggingConfigResponse>(config);
|
||||
|
||||
let result: AwsBedrockKey["awsLoggingStatus"] = "unknown";
|
||||
|
||||
if (status === 200) {
|
||||
const { loggingConfig } = data;
|
||||
const loggingEnabled = !!loggingConfig?.textDataDeliveryEnabled;
|
||||
this.log.debug(
|
||||
{ key: key.hash, loggingConfig, loggingEnabled },
|
||||
"AWS model invocation logging test complete."
|
||||
);
|
||||
result = loggingEnabled ? "enabled" : "disabled";
|
||||
} else {
|
||||
const errorType = (headers["x-amzn-errortype"] as string).split(":")[0];
|
||||
this.log.debug(
|
||||
{ key: key.hash, errorType, data, status },
|
||||
"Can't determine AWS model invocation logging status."
|
||||
);
|
||||
}
|
||||
|
||||
this.updateKey(key.hash, { awsLoggingStatus: result });
|
||||
}
|
||||
|
||||
static errorIsAwsError(error: AxiosError): error is AxiosError<AwsError> {
|
||||
const headers = error.response?.headers;
|
||||
if (!headers) return false;
|
||||
return !!headers["x-amzn-errortype"];
|
||||
}
|
||||
|
||||
/** Given an Axios request, sign it with the given key. */
|
||||
static async signRequestForAws(
|
||||
axiosRequest: AxiosRequestConfig,
|
||||
key: AwsBedrockKey,
|
||||
awsService = "bedrock"
|
||||
) {
|
||||
const creds = AwsKeyChecker.getCredentialsFromKey(key);
|
||||
const { accessKeyId, secretAccessKey, region } = creds;
|
||||
const { method, url: axUrl, headers: axHeaders, data } = axiosRequest;
|
||||
const url = new URL(axUrl!);
|
||||
|
||||
let plainHeaders = {};
|
||||
if (axHeaders instanceof AxiosHeaders) {
|
||||
plainHeaders = axHeaders.toJSON();
|
||||
} else if (typeof axHeaders === "object") {
|
||||
plainHeaders = axHeaders;
|
||||
}
|
||||
|
||||
const request = new HttpRequest({
|
||||
method,
|
||||
protocol: "https:",
|
||||
hostname: url.hostname,
|
||||
path: url.pathname + url.search,
|
||||
headers: { Host: url.hostname, ...plainHeaders },
|
||||
});
|
||||
|
||||
if (data) {
|
||||
request.body = JSON.stringify(data);
|
||||
}
|
||||
|
||||
const signer = new SignatureV4({
|
||||
sha256: Sha256,
|
||||
credentials: { accessKeyId, secretAccessKey },
|
||||
region,
|
||||
service: awsService,
|
||||
});
|
||||
const signedRequest = await signer.sign(request);
|
||||
axiosRequest.headers = signedRequest.headers;
|
||||
}
|
||||
|
||||
static getCredentialsFromKey(key: AwsBedrockKey) {
|
||||
const [accessKeyId, secretAccessKey, region] = key.key.split(":");
|
||||
if (!accessKeyId || !secretAccessKey || !region) {
|
||||
throw new Error("Invalid AWS Bedrock key");
|
||||
}
|
||||
return { accessKeyId, secretAccessKey, region };
|
||||
}
|
||||
}
|
|
@ -3,6 +3,7 @@ import { Key, KeyProvider } from "..";
|
|||
import { config } from "../../../config";
|
||||
import { logger } from "../../../logger";
|
||||
import type { AwsBedrockModelFamily } from "../../models";
|
||||
import { AwsKeyChecker } from "./checker";
|
||||
|
||||
// https://docs.aws.amazon.com/bedrock/latest/userguide/model-ids-arns.html
|
||||
export const AWS_BEDROCK_SUPPORTED_MODELS = [
|
||||
|
@ -23,6 +24,13 @@ export interface AwsBedrockKey extends Key, AwsBedrockKeyUsage {
|
|||
rateLimitedAt: number;
|
||||
/** The time until which this key is rate limited. */
|
||||
rateLimitedUntil: number;
|
||||
/**
|
||||
* The confirmed logging status of this key. This is "unknown" until we
|
||||
* receive a response from the AWS API. Keys which are logged, or not
|
||||
* confirmed as not being logged, won't be used unless ALLOW_AWS_LOGGING is
|
||||
* set.
|
||||
*/
|
||||
awsLoggingStatus: "unknown" | "disabled" | "enabled";
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -41,6 +49,7 @@ export class AwsBedrockKeyProvider implements KeyProvider<AwsBedrockKey> {
|
|||
readonly service = "aws";
|
||||
|
||||
private keys: AwsBedrockKey[] = [];
|
||||
private checker?: AwsKeyChecker;
|
||||
private log = logger.child({ module: "key-provider", service: this.service });
|
||||
|
||||
constructor() {
|
||||
|
@ -58,12 +67,13 @@ export class AwsBedrockKeyProvider implements KeyProvider<AwsBedrockKey> {
|
|||
key,
|
||||
service: this.service,
|
||||
modelFamilies: ["aws-claude"],
|
||||
isTrial: false,
|
||||
isDisabled: false,
|
||||
isRevoked: false,
|
||||
promptCount: 0,
|
||||
lastUsed: 0,
|
||||
rateLimitedAt: 0,
|
||||
rateLimitedUntil: 0,
|
||||
awsLoggingStatus: "unknown",
|
||||
hash: `aws-${crypto
|
||||
.createHash("sha256")
|
||||
.update(key)
|
||||
|
@ -77,14 +87,22 @@ export class AwsBedrockKeyProvider implements KeyProvider<AwsBedrockKey> {
|
|||
this.log.info({ keyCount: this.keys.length }, "Loaded AWS Bedrock keys.");
|
||||
}
|
||||
|
||||
public init() {}
|
||||
public init() {
|
||||
if (config.checkKeys) {
|
||||
this.checker = new AwsKeyChecker(this.keys, this.update.bind(this));
|
||||
this.checker.start();
|
||||
}
|
||||
}
|
||||
|
||||
public list() {
|
||||
return this.keys.map((k) => Object.freeze({ ...k, key: undefined }));
|
||||
}
|
||||
|
||||
public get(_model: AwsBedrockModel) {
|
||||
const availableKeys = this.keys.filter((k) => !k.isDisabled);
|
||||
const availableKeys = this.keys.filter((k) => {
|
||||
const isNotLogged = k.awsLoggingStatus === "disabled";
|
||||
return !k.isDisabled && (isNotLogged || config.allowAwsLogging);
|
||||
});
|
||||
if (availableKeys.length === 0) {
|
||||
throw new Error("No AWS Bedrock keys available");
|
||||
}
|
||||
|
@ -176,5 +194,9 @@ export class AwsBedrockKeyProvider implements KeyProvider<AwsBedrockKey> {
|
|||
key.rateLimitedUntil = now + RATE_LIMIT_LOCKOUT;
|
||||
}
|
||||
|
||||
public recheck() {}
|
||||
public recheck() {
|
||||
this.keys.forEach(({ hash }) =>
|
||||
this.update(hash, { lastChecked: 0, isDisabled: false })
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -23,12 +23,12 @@ export interface Key {
|
|||
readonly key: string;
|
||||
/** The service that this key is for. */
|
||||
service: LLMService;
|
||||
/** Whether this is a free trial key. These are prioritized over paid keys if they can fulfill the request. */
|
||||
isTrial: boolean;
|
||||
/** The model families that this key has access to. */
|
||||
modelFamilies: ModelFamily[];
|
||||
/** Whether this key is currently disabled, meaning its quota has been exceeded or it has been revoked. */
|
||||
isDisabled: boolean;
|
||||
/** Whether this key specifically has been revoked. */
|
||||
isRevoked: boolean;
|
||||
/** The number of prompts that have been sent with this key. */
|
||||
promptCount: number;
|
||||
/** The time at which this key was last used. */
|
||||
|
|
|
@ -0,0 +1,119 @@
|
|||
import pino from "pino";
|
||||
import { logger } from "../../logger";
|
||||
import { Key } from "./index";
|
||||
import { AxiosError } from "axios";
|
||||
|
||||
type KeyCheckerOptions = {
|
||||
service: string;
|
||||
keyCheckPeriod: number;
|
||||
minCheckInterval: number;
|
||||
}
|
||||
|
||||
export abstract class KeyCheckerBase<TKey extends Key> {
|
||||
protected readonly service: string;
|
||||
/** Minimum time in between any two key checks. */
|
||||
protected readonly MIN_CHECK_INTERVAL: number;
|
||||
/**
|
||||
* Minimum time in between checks for a given key. Because we can no longer
|
||||
* read quota usage, there is little reason to check a single key more often
|
||||
* than this.
|
||||
*/
|
||||
protected readonly KEY_CHECK_PERIOD: number;
|
||||
protected readonly keys: TKey[] = [];
|
||||
protected log: pino.Logger;
|
||||
protected timeout?: NodeJS.Timeout;
|
||||
protected lastCheck = 0;
|
||||
|
||||
protected constructor(keys: TKey[], opts: KeyCheckerOptions) {
|
||||
const { service, keyCheckPeriod, minCheckInterval } = opts;
|
||||
this.keys = keys;
|
||||
this.KEY_CHECK_PERIOD = keyCheckPeriod;
|
||||
this.MIN_CHECK_INTERVAL = minCheckInterval;
|
||||
this.service = service;
|
||||
this.log = logger.child({ module: "key-checker", service });
|
||||
}
|
||||
|
||||
public start() {
|
||||
this.log.info("Starting key checker...");
|
||||
this.timeout = setTimeout(() => this.scheduleNextCheck(), 0);
|
||||
}
|
||||
|
||||
public stop() {
|
||||
if (this.timeout) {
|
||||
this.log.debug("Stopping key checker...");
|
||||
clearTimeout(this.timeout);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedules the next check. If there are still keys yet to be checked, it
|
||||
* will schedule a check immediately for the next unchecked key. Otherwise,
|
||||
* it will schedule a check for the least recently checked key, respecting
|
||||
* the minimum check interval.
|
||||
*/
|
||||
public scheduleNextCheck() {
|
||||
const callId = Math.random().toString(36).slice(2, 8);
|
||||
const timeoutId = this.timeout?.[Symbol.toPrimitive]?.();
|
||||
const checkLog = this.log.child({ callId, timeoutId });
|
||||
|
||||
const enabledKeys = this.keys.filter((key) => !key.isDisabled);
|
||||
checkLog.debug({ enabled: enabledKeys.length }, "Scheduling next check...");
|
||||
|
||||
clearTimeout(this.timeout);
|
||||
|
||||
if (enabledKeys.length === 0) {
|
||||
checkLog.warn("All keys are disabled. Key checker stopping.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Perform startup checks for any keys that haven't been checked yet.
|
||||
const uncheckedKeys = enabledKeys.filter((key) => !key.lastChecked);
|
||||
checkLog.debug({ unchecked: uncheckedKeys.length }, "# of unchecked keys");
|
||||
if (uncheckedKeys.length > 0) {
|
||||
const keysToCheck = uncheckedKeys.slice(0, 12);
|
||||
|
||||
this.timeout = setTimeout(async () => {
|
||||
try {
|
||||
await Promise.all(keysToCheck.map((key) => this.checkKey(key)));
|
||||
} catch (error) {
|
||||
this.log.error({ error }, "Error checking one or more keys.");
|
||||
}
|
||||
checkLog.info("Batch complete.");
|
||||
this.scheduleNextCheck();
|
||||
}, 250);
|
||||
|
||||
checkLog.info(
|
||||
{
|
||||
batch: keysToCheck.map((k) => k.hash),
|
||||
remaining: uncheckedKeys.length - keysToCheck.length,
|
||||
newTimeoutId: this.timeout?.[Symbol.toPrimitive]?.(),
|
||||
},
|
||||
"Scheduled batch check."
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Schedule the next check for the oldest key.
|
||||
const oldestKey = enabledKeys.reduce((oldest, key) =>
|
||||
key.lastChecked < oldest.lastChecked ? key : oldest
|
||||
);
|
||||
|
||||
// Don't check any individual key too often.
|
||||
// Don't check anything at all at a rate faster than once per 3 seconds.
|
||||
const nextCheck = Math.max(
|
||||
oldestKey.lastChecked + this.KEY_CHECK_PERIOD,
|
||||
this.lastCheck + this.MIN_CHECK_INTERVAL
|
||||
);
|
||||
|
||||
const delay = nextCheck - Date.now();
|
||||
this.timeout = setTimeout(() => this.checkKey(oldestKey), delay);
|
||||
checkLog.debug(
|
||||
{ key: oldestKey.hash, nextCheck: new Date(nextCheck), delay },
|
||||
"Scheduled single key check."
|
||||
);
|
||||
}
|
||||
|
||||
protected abstract checkKey(key: TKey): Promise<void>;
|
||||
|
||||
protected abstract handleAxiosError(key: TKey, error: AxiosError): void;
|
||||
}
|
|
@ -53,11 +53,9 @@ export class KeyPool {
|
|||
public disable(key: Key, reason: "quota" | "revoked"): void {
|
||||
const service = this.getKeyProvider(key.service);
|
||||
service.disable(key);
|
||||
service.update(key.hash, { isRevoked: reason === "revoked" });
|
||||
if (service instanceof OpenAIKeyProvider) {
|
||||
service.update(key.hash, {
|
||||
isRevoked: reason === "revoked",
|
||||
isOverQuota: reason === "quota",
|
||||
});
|
||||
service.update(key.hash, { isOverQuota: reason === "quota" });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,17 +1,10 @@
|
|||
import axios, { AxiosError } from "axios";
|
||||
import { logger } from "../../../logger";
|
||||
import type { OpenAIKey, OpenAIKeyProvider } from "./provider";
|
||||
import type { OpenAIModelFamily } from "../../models";
|
||||
import { KeyCheckerBase } from "../key-checker-base";
|
||||
import type { OpenAIKey, OpenAIKeyProvider } from "./provider";
|
||||
|
||||
/** Minimum time in between any two key checks. */
|
||||
const MIN_CHECK_INTERVAL = 3 * 1000; // 3 seconds
|
||||
/**
|
||||
* Minimum time in between checks for a given key. Because we can no longer
|
||||
* read quota usage, there is little reason to check a single key more often
|
||||
* than this.
|
||||
**/
|
||||
const KEY_CHECK_PERIOD = 60 * 60 * 1000; // 1 hour
|
||||
|
||||
const POST_CHAT_COMPLETIONS_URL = "https://api.openai.com/v1/chat/completions";
|
||||
const GET_MODELS_URL = "https://api.openai.com/v1/models";
|
||||
const GET_ORGANIZATIONS_URL = "https://api.openai.com/v1/organizations";
|
||||
|
@ -31,104 +24,21 @@ type OpenAIError = {
|
|||
type CloneFn = typeof OpenAIKeyProvider.prototype.clone;
|
||||
type UpdateFn = typeof OpenAIKeyProvider.prototype.update;
|
||||
|
||||
export class OpenAIKeyChecker {
|
||||
private readonly keys: OpenAIKey[];
|
||||
private cloneKey: CloneFn;
|
||||
private updateKey: UpdateFn;
|
||||
private log = logger.child({ module: "key-checker", service: "openai" });
|
||||
private timeout?: NodeJS.Timeout;
|
||||
private lastCheck = 0;
|
||||
export class OpenAIKeyChecker extends KeyCheckerBase<OpenAIKey> {
|
||||
private readonly cloneKey: CloneFn;
|
||||
private readonly updateKey: UpdateFn;
|
||||
|
||||
constructor(keys: OpenAIKey[], cloneFn: CloneFn, updateKey: UpdateFn) {
|
||||
this.keys = keys;
|
||||
super(keys, {
|
||||
service: "openai",
|
||||
keyCheckPeriod: KEY_CHECK_PERIOD,
|
||||
minCheckInterval: MIN_CHECK_INTERVAL,
|
||||
});
|
||||
this.cloneKey = cloneFn;
|
||||
this.updateKey = updateKey;
|
||||
}
|
||||
|
||||
public start() {
|
||||
this.log.info("Starting key checker...");
|
||||
this.timeout = setTimeout(() => this.scheduleNextCheck(), 0);
|
||||
}
|
||||
|
||||
public stop() {
|
||||
if (this.timeout) {
|
||||
this.log.debug("Stopping key checker...");
|
||||
clearTimeout(this.timeout);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedules the next check. If there are still keys yet to be checked, it
|
||||
* will schedule a check immediately for the next unchecked key. Otherwise,
|
||||
* it will schedule a check for the least recently checked key, respecting
|
||||
* the minimum check interval.
|
||||
**/
|
||||
public scheduleNextCheck() {
|
||||
const callId = Math.random().toString(36).slice(2, 8);
|
||||
const timeoutId = this.timeout?.[Symbol.toPrimitive]?.();
|
||||
const checkLog = this.log.child({ callId, timeoutId });
|
||||
|
||||
const enabledKeys = this.keys.filter((key) => !key.isDisabled);
|
||||
checkLog.debug({ enabled: enabledKeys.length }, "Scheduling next check...");
|
||||
|
||||
//
|
||||
clearTimeout(this.timeout);
|
||||
|
||||
if (enabledKeys.length === 0) {
|
||||
checkLog.warn("All keys are disabled. Key checker stopping.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Perform startup checks for any keys that haven't been checked yet.
|
||||
const uncheckedKeys = enabledKeys.filter((key) => !key.lastChecked);
|
||||
checkLog.debug({ unchecked: uncheckedKeys.length }, "# of unchecked keys");
|
||||
if (uncheckedKeys.length > 0) {
|
||||
const keysToCheck = uncheckedKeys.slice(0, 12);
|
||||
|
||||
this.timeout = setTimeout(async () => {
|
||||
try {
|
||||
await Promise.all(keysToCheck.map((key) => this.checkKey(key)));
|
||||
} catch (error) {
|
||||
this.log.error({ error }, "Error checking one or more keys.");
|
||||
}
|
||||
checkLog.info("Batch complete.");
|
||||
this.scheduleNextCheck();
|
||||
}, 250);
|
||||
|
||||
checkLog.info(
|
||||
{
|
||||
batch: keysToCheck.map((k) => k.hash),
|
||||
remaining: uncheckedKeys.length - keysToCheck.length,
|
||||
newTimeoutId: this.timeout?.[Symbol.toPrimitive]?.(),
|
||||
},
|
||||
"Scheduled batch check."
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Schedule the next check for the oldest key.
|
||||
const oldestKey = enabledKeys.reduce((oldest, key) =>
|
||||
key.lastChecked < oldest.lastChecked ? key : oldest
|
||||
);
|
||||
|
||||
// Don't check any individual key too often.
|
||||
// Don't check anything at all at a rate faster than once per 3 seconds.
|
||||
const nextCheck = Math.max(
|
||||
oldestKey.lastChecked + KEY_CHECK_PERIOD,
|
||||
this.lastCheck + MIN_CHECK_INTERVAL
|
||||
);
|
||||
|
||||
const delay = nextCheck - Date.now();
|
||||
this.timeout = setTimeout(() => this.checkKey(oldestKey), delay);
|
||||
checkLog.debug(
|
||||
{ key: oldestKey.hash, nextCheck: new Date(nextCheck), delay },
|
||||
"Scheduled single key check."
|
||||
);
|
||||
}
|
||||
|
||||
private async checkKey(key: OpenAIKey) {
|
||||
// It's possible this key might have been disabled while we were waiting
|
||||
// for the next check.
|
||||
protected async checkKey(key: OpenAIKey) {
|
||||
if (key.isDisabled) {
|
||||
this.log.warn({ key: key.hash }, "Skipping check for disabled key.");
|
||||
this.scheduleNextCheck();
|
||||
|
@ -232,7 +142,7 @@ export class OpenAIKeyChecker {
|
|||
this.cloneKey(key.hash, ids);
|
||||
}
|
||||
|
||||
private handleAxiosError(key: OpenAIKey, error: AxiosError) {
|
||||
protected handleAxiosError(key: OpenAIKey, error: AxiosError) {
|
||||
if (error.response && OpenAIKeyChecker.errorIsOpenAIError(error)) {
|
||||
const { status, data } = error.response;
|
||||
if (status === 401) {
|
||||
|
|
|
@ -36,8 +36,8 @@ export interface OpenAIKey extends Key, OpenAIKeyUsage {
|
|||
* status separately.
|
||||
*/
|
||||
organizationId?: string;
|
||||
/** Set when key check returns a 401. */
|
||||
isRevoked: boolean;
|
||||
/** Whether this is a free trial key. These are prioritized over paid keys if they can fulfill the request. */
|
||||
isTrial: boolean;
|
||||
/** Set when key check returns a non-transient 429. */
|
||||
isOverQuota: boolean;
|
||||
/** The time at which this key was last rate limited. */
|
||||
|
|
|
@ -67,8 +67,8 @@ export class GooglePalmKeyProvider implements KeyProvider<GooglePalmKey> {
|
|||
key,
|
||||
service: this.service,
|
||||
modelFamilies: ["bison"],
|
||||
isTrial: false,
|
||||
isDisabled: false,
|
||||
isRevoked: false,
|
||||
promptCount: 0,
|
||||
lastUsed: 0,
|
||||
rateLimitedAt: 0,
|
||||
|
|
Loading…
Reference in New Issue