@oh-my-pi/pi-ai 16.4.8 → 16.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +39 -0
- package/dist/types/auth-broker/remote-store.d.ts +2 -0
- package/dist/types/auth-broker/wire-schemas.d.ts +28 -0
- package/dist/types/auth-gateway/http.d.ts +18 -1
- package/dist/types/auth-retry.d.ts +44 -20
- package/dist/types/auth-storage.d.ts +51 -10
- package/dist/types/error/flags.d.ts +1 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/providers/google-auth.d.ts +0 -8
- package/dist/types/providers/google-shared.d.ts +0 -13
- package/dist/types/providers/openai-chat-server-schema.d.ts +3 -3
- package/dist/types/providers/openai-shared.d.ts +2 -0
- package/dist/types/registry/oauth/types.d.ts +8 -0
- package/dist/types/types.d.ts +0 -16
- package/dist/types/usage/cursor.d.ts +3 -0
- package/dist/types/usage/openai-codex-reset.d.ts +1 -1
- package/dist/types/usage/zai.d.ts +2 -1
- package/dist/types/usage.d.ts +4 -0
- package/package.json +7 -7
- package/src/auth-broker/discover.ts +22 -3
- package/src/auth-broker/remote-store.ts +103 -10
- package/src/auth-broker/wire-schemas.ts +4 -0
- package/src/auth-gateway/http.ts +35 -2
- package/src/auth-gateway/server.ts +26 -4
- package/src/auth-retry.ts +191 -53
- package/src/auth-storage.ts +614 -109
- package/src/error/auth-classify.ts +2 -1
- package/src/error/flags.ts +10 -0
- package/src/error/provider.ts +7 -6
- package/src/index.ts +1 -0
- package/src/providers/cursor.ts +10 -1
- package/src/providers/google-auth.ts +0 -20
- package/src/providers/google-shared.ts +0 -13
- package/src/providers/google-vertex.ts +62 -108
- package/src/providers/google.ts +11 -51
- package/src/providers/openai-chat-server-schema.ts +2 -1
- package/src/providers/openai-codex/request-transformer.ts +1 -1
- package/src/providers/openai-codex-responses.ts +54 -27
- package/src/providers/openai-shared.ts +6 -3
- package/src/registry/oauth/anthropic.ts +57 -24
- package/src/registry/oauth/oauth.html +1 -1
- package/src/registry/oauth/types.ts +8 -0
- package/src/stream.ts +19 -33
- package/src/types.ts +0 -16
- package/src/usage/cursor.ts +192 -0
- package/src/usage/openai-codex-reset.ts +6 -2
- package/src/usage/zai.ts +49 -6
- package/src/usage.ts +4 -0
- package/dist/types/providers/google-interactions.d.ts +0 -65
- package/src/providers/google-interactions.ts +0 -753
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@oh-my-pi/pi-ai",
|
|
4
|
-
"version": "16.
|
|
4
|
+
"version": "16.5.1",
|
|
5
5
|
"description": "Unified LLM API with automatic model discovery and provider configuration",
|
|
6
6
|
"homepage": "https://omp.sh",
|
|
7
7
|
"author": "Can Boluk",
|
|
@@ -37,15 +37,15 @@
|
|
|
37
37
|
"fmt": "biome format --write ."
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
|
40
|
-
"@bufbuild/protobuf": "^2.12.
|
|
41
|
-
"@oh-my-pi/pi-catalog": "16.
|
|
42
|
-
"@oh-my-pi/pi-utils": "16.
|
|
43
|
-
"@oh-my-pi/pi-wire": "16.
|
|
44
|
-
"arktype": "2.2.
|
|
40
|
+
"@bufbuild/protobuf": "^2.12.1",
|
|
41
|
+
"@oh-my-pi/pi-catalog": "16.5.1",
|
|
42
|
+
"@oh-my-pi/pi-utils": "16.5.1",
|
|
43
|
+
"@oh-my-pi/pi-wire": "16.5.1",
|
|
44
|
+
"arktype": "2.2.3",
|
|
45
45
|
"zod": "^4"
|
|
46
46
|
},
|
|
47
47
|
"devDependencies": {
|
|
48
|
-
"@bufbuild/protoc-gen-es": "^2.12.
|
|
48
|
+
"@bufbuild/protoc-gen-es": "^2.12.1",
|
|
49
49
|
"@types/bun": "^1.3.14"
|
|
50
50
|
},
|
|
51
51
|
"engines": {
|
|
@@ -72,6 +72,26 @@ interface ConfigSnapshot {
|
|
|
72
72
|
token?: string;
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
+
/**
|
|
76
|
+
* Resolve a dotted config key (e.g. `auth.broker.url`) against a parsed YAML
|
|
77
|
+
* record, accepting both nested form (`auth: { broker: { url } }`) and the
|
|
78
|
+
* legacy flat literal-dot key (`"auth.broker.url": ...`). Nested wins when both
|
|
79
|
+
* are present. Returns the value only when it is a string.
|
|
80
|
+
*/
|
|
81
|
+
function readDottedString(record: Record<string, unknown>, dottedKey: string): string | undefined {
|
|
82
|
+
let current: unknown = record;
|
|
83
|
+
for (const segment of dottedKey.split(".")) {
|
|
84
|
+
if (current === null || typeof current !== "object" || Array.isArray(current)) {
|
|
85
|
+
current = undefined;
|
|
86
|
+
break;
|
|
87
|
+
}
|
|
88
|
+
current = (current as Record<string, unknown>)[segment];
|
|
89
|
+
}
|
|
90
|
+
if (typeof current === "string") return current;
|
|
91
|
+
const flat = record[dottedKey];
|
|
92
|
+
return typeof flat === "string" ? flat : undefined;
|
|
93
|
+
}
|
|
94
|
+
|
|
75
95
|
async function readConfigYaml(agentDir: string): Promise<ConfigSnapshot> {
|
|
76
96
|
for (const filename of MAIN_CONFIG_FILENAMES) {
|
|
77
97
|
const configPath = path.join(agentDir, filename);
|
|
@@ -80,9 +100,8 @@ async function readConfigYaml(agentDir: string): Promise<ConfigSnapshot> {
|
|
|
80
100
|
const parsed = YAML.parse(raw);
|
|
81
101
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
|
|
82
102
|
const record = parsed as Record<string, unknown>;
|
|
83
|
-
const url =
|
|
84
|
-
const token =
|
|
85
|
-
typeof record["auth.broker.token"] === "string" ? (record["auth.broker.token"] as string) : undefined;
|
|
103
|
+
const url = readDottedString(record, "auth.broker.url");
|
|
104
|
+
const token = readDottedString(record, "auth.broker.token");
|
|
86
105
|
return { url, token };
|
|
87
106
|
} catch (err) {
|
|
88
107
|
if (isEnoent(err)) continue;
|
|
@@ -143,14 +143,24 @@ interface UsageCacheEntry {
|
|
|
143
143
|
|
|
144
144
|
function usageOverlayKey(
|
|
145
145
|
provider: Provider,
|
|
146
|
-
ids: { accountId?: string; email?: string; projectId?: string },
|
|
146
|
+
ids: { accountId?: string; email?: string; projectId?: string; orgId?: string },
|
|
147
147
|
): string | undefined {
|
|
148
|
+
// Org first: one account email can hold several organizations (Anthropic
|
|
149
|
+
// Team seat + personal Max), each with its own limit pools. Keying the
|
|
150
|
+
// overlay by account/email would merge the two pools' header ingests.
|
|
151
|
+
// But the org alone is not enough either: two Team members share the org
|
|
152
|
+
// id while drawing on per-user pools, so the key stays qualified by the
|
|
153
|
+
// member's own base identity whenever one is known.
|
|
154
|
+
let base: string | undefined;
|
|
148
155
|
const accountId = ids.accountId?.trim().toLowerCase();
|
|
149
|
-
if (accountId) return `${provider}\0account:${accountId}`;
|
|
150
156
|
const email = ids.email?.trim().toLowerCase();
|
|
151
|
-
if (email) return `${provider}\0email:${email}`;
|
|
152
157
|
const projectId = ids.projectId?.trim().toLowerCase();
|
|
153
|
-
if (
|
|
158
|
+
if (accountId) base = `account:${accountId}`;
|
|
159
|
+
else if (email) base = `email:${email}`;
|
|
160
|
+
else if (projectId) base = `project:${projectId}`;
|
|
161
|
+
const orgId = ids.orgId?.trim().toLowerCase();
|
|
162
|
+
if (orgId) return base ? `${provider}\0org:${orgId}|${base}` : `${provider}\0org:${orgId}`;
|
|
163
|
+
if (base) return `${provider}\0${base}`;
|
|
154
164
|
return undefined;
|
|
155
165
|
}
|
|
156
166
|
|
|
@@ -787,6 +797,13 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
|
|
|
787
797
|
this.#cache.set(key, { value, expiresAtSec });
|
|
788
798
|
}
|
|
789
799
|
|
|
800
|
+
/** Drop all cache rows whose keys start with the supplied prefix. */
|
|
801
|
+
deleteCachePrefix(prefix: string): void {
|
|
802
|
+
for (const key of this.#cache.keys()) {
|
|
803
|
+
if (key.startsWith(prefix)) this.#cache.delete(key);
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
|
|
790
807
|
cleanExpiredCache(): void {
|
|
791
808
|
const nowSec = Math.floor(Date.now() / 1000);
|
|
792
809
|
for (const [key, entry] of this.#cache) {
|
|
@@ -987,12 +1004,55 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
|
|
|
987
1004
|
* usage data" (ranking proceeds without a usage signal for this credential).
|
|
988
1005
|
*/
|
|
989
1006
|
function matchUsageReport(reports: UsageReport[], provider: Provider, credential: OAuthCredential): UsageReport | null {
|
|
990
|
-
const
|
|
991
|
-
if (
|
|
992
|
-
|
|
1007
|
+
const all = reports.filter(report => report.provider === provider);
|
|
1008
|
+
if (all.length === 0) return null;
|
|
1009
|
+
// Org precedence, decisive on EITHER side: an org-scoped credential may
|
|
1010
|
+
// only take its own org's report, and an org-less (legacy) credential may
|
|
1011
|
+
// only take org-less reports — the shared email/account would otherwise
|
|
1012
|
+
// hand one subscription the OTHER subscription's pool (e.g. mark healthy
|
|
1013
|
+
// Max exhausted via Team's report, or rank a legacy row on a sibling's
|
|
1014
|
+
// numbers).
|
|
1015
|
+
const orgId = credential.orgId?.trim().toLowerCase();
|
|
993
1016
|
const accountId = credential.accountId?.trim().toLowerCase();
|
|
994
1017
|
const email = credential.email?.trim().toLowerCase();
|
|
995
1018
|
const projectId = credential.projectId?.trim().toLowerCase();
|
|
1019
|
+
if (orgId) {
|
|
1020
|
+
const sameOrg: UsageReport[] = [];
|
|
1021
|
+
let sawReportOrg = false;
|
|
1022
|
+
for (const report of all) {
|
|
1023
|
+
const metaOrg = readMetadataString((report.metadata ?? {}) as Record<string, unknown>, "orgId");
|
|
1024
|
+
if (metaOrg) {
|
|
1025
|
+
sawReportOrg = true;
|
|
1026
|
+
if (metaOrg.toLowerCase() === orgId) sameOrg.push(report);
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
// Org-attributed reports exist: the shared org is a GATE, not a match.
|
|
1030
|
+
// Two Team members share the org id while drawing on per-user pools,
|
|
1031
|
+
// so the credential's own base identity must still line up inside the
|
|
1032
|
+
// same-org subset — a lone sibling report is NOT ours. An org-only
|
|
1033
|
+
// credential (no base identifiers) takes the lone same-org report and
|
|
1034
|
+
// treats several as ambiguous. None in our org → "no usage data"
|
|
1035
|
+
// rather than mis-attributing another org's pool.
|
|
1036
|
+
if (sawReportOrg) {
|
|
1037
|
+
if (accountId || email || projectId) {
|
|
1038
|
+
for (const report of sameOrg) {
|
|
1039
|
+
if (reportMatchesIdentity(report, accountId, email, projectId)) return report;
|
|
1040
|
+
}
|
|
1041
|
+
return null;
|
|
1042
|
+
}
|
|
1043
|
+
return sameOrg.length === 1 ? sameOrg[0]! : null;
|
|
1044
|
+
}
|
|
1045
|
+
// No surviving report carries an org at all: presence mismatch is a
|
|
1046
|
+
// non-match too — the sole org-less report may be a legacy sibling
|
|
1047
|
+
// row's pool, and handing it to a scoped credential would rank/block
|
|
1048
|
+
// on the wrong quota. "No usage data" degrades gracefully instead.
|
|
1049
|
+
return null;
|
|
1050
|
+
}
|
|
1051
|
+
const candidates = all.filter(
|
|
1052
|
+
report => !readMetadataString((report.metadata ?? {}) as Record<string, unknown>, "orgId"),
|
|
1053
|
+
);
|
|
1054
|
+
if (candidates.length === 0) return null;
|
|
1055
|
+
if (all.length === 1 && candidates.length === 1) return candidates[0];
|
|
996
1056
|
for (const report of candidates) {
|
|
997
1057
|
if (reportMatchesIdentity(report, accountId, email, projectId)) return report;
|
|
998
1058
|
}
|
|
@@ -1000,15 +1060,48 @@ function matchUsageReport(reports: UsageReport[], provider: Provider, credential
|
|
|
1000
1060
|
}
|
|
1001
1061
|
|
|
1002
1062
|
function findMatchingReportIndex(reports: UsageReport[], overlay: UsageReport): number {
|
|
1003
|
-
const
|
|
1063
|
+
const all = reports
|
|
1004
1064
|
.map((report, index) => ({ report, index }))
|
|
1005
1065
|
.filter(candidate => candidate.report.provider === overlay.provider);
|
|
1006
|
-
if (
|
|
1007
|
-
if (candidates.length === 1) return candidates[0]!.index;
|
|
1066
|
+
if (all.length === 0) return -1;
|
|
1008
1067
|
const metadata = (overlay.metadata ?? {}) as Record<string, unknown>;
|
|
1068
|
+
// Org precedence — mirror matchUsageReport: an org-attributed overlay may
|
|
1069
|
+
// only merge into a report of the SAME org, and an org-less overlay may
|
|
1070
|
+
// only merge into an org-less report. Within the same org the overlay's
|
|
1071
|
+
// base identity must still match — two Team members' reports share the
|
|
1072
|
+
// org id but must not swallow each other's header ingests.
|
|
1073
|
+
const overlayOrg = readMetadataString(metadata, "orgId")?.toLowerCase();
|
|
1009
1074
|
const accountId = readMetadataString(metadata, "accountId")?.toLowerCase();
|
|
1010
1075
|
const email = readMetadataString(metadata, "email")?.toLowerCase();
|
|
1011
1076
|
const projectId = readMetadataString(metadata, "projectId")?.toLowerCase();
|
|
1077
|
+
if (overlayOrg) {
|
|
1078
|
+
const sameOrg: { report: UsageReport; index: number }[] = [];
|
|
1079
|
+
let sawReportOrg = false;
|
|
1080
|
+
for (const candidate of all) {
|
|
1081
|
+
const candidateOrg = readMetadataString((candidate.report.metadata ?? {}) as Record<string, unknown>, "orgId");
|
|
1082
|
+
if (candidateOrg) {
|
|
1083
|
+
sawReportOrg = true;
|
|
1084
|
+
if (candidateOrg.toLowerCase() === overlayOrg) sameOrg.push(candidate);
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
if (sawReportOrg) {
|
|
1088
|
+
if (accountId || email || projectId) {
|
|
1089
|
+
for (const candidate of sameOrg) {
|
|
1090
|
+
if (reportMatchesIdentity(candidate.report, accountId, email, projectId)) return candidate.index;
|
|
1091
|
+
}
|
|
1092
|
+
return -1;
|
|
1093
|
+
}
|
|
1094
|
+
return sameOrg.length === 1 ? sameOrg[0]!.index : -1;
|
|
1095
|
+
}
|
|
1096
|
+
// Presence mismatch — mirror matchUsageReport: an org-scoped overlay
|
|
1097
|
+
// never merges into an org-less report; it becomes its own report row.
|
|
1098
|
+
return -1;
|
|
1099
|
+
}
|
|
1100
|
+
const candidates = all.filter(
|
|
1101
|
+
candidate => !readMetadataString((candidate.report.metadata ?? {}) as Record<string, unknown>, "orgId"),
|
|
1102
|
+
);
|
|
1103
|
+
if (candidates.length === 0) return -1;
|
|
1104
|
+
if (all.length === 1 && candidates.length === 1) return candidates[0]!.index;
|
|
1012
1105
|
for (const candidate of candidates) {
|
|
1013
1106
|
if (reportMatchesIdentity(candidate.report, accountId, email, projectId)) return candidate.index;
|
|
1014
1107
|
}
|
|
@@ -32,6 +32,8 @@ export const oauthCredentialSchema = type({
|
|
|
32
32
|
"projectId?": "string",
|
|
33
33
|
"email?": "string",
|
|
34
34
|
"accountId?": "string",
|
|
35
|
+
"orgId?": "string",
|
|
36
|
+
"orgName?": "string",
|
|
35
37
|
});
|
|
36
38
|
|
|
37
39
|
/** OAuth credential as it appears in broker snapshots — refresh replaced with sentinel. */
|
|
@@ -45,6 +47,8 @@ export const remoteOauthCredentialSchema = type({
|
|
|
45
47
|
"projectId?": "string",
|
|
46
48
|
"email?": "string",
|
|
47
49
|
"accountId?": "string",
|
|
50
|
+
"orgId?": "string",
|
|
51
|
+
"orgName?": "string",
|
|
48
52
|
});
|
|
49
53
|
|
|
50
54
|
export const apiKeyCredentialSchema = type({
|
package/src/auth-gateway/http.ts
CHANGED
|
@@ -5,19 +5,50 @@
|
|
|
5
5
|
* and peer-resolution logic.
|
|
6
6
|
*/
|
|
7
7
|
import { timingSafeEqual as nodeTimingSafeEqual } from "node:crypto";
|
|
8
|
+
import type { Api, AssistantMessage, Model } from "../types";
|
|
8
9
|
|
|
9
10
|
const JSON_HEADERS = {
|
|
10
11
|
"Content-Type": "application/json",
|
|
11
12
|
"X-Content-Type-Options": "nosniff",
|
|
12
13
|
} as const;
|
|
13
14
|
|
|
14
|
-
export function json(status: number, body: unknown): Response {
|
|
15
|
+
export function json(status: number, body: unknown, headers?: Record<string, string>): Response {
|
|
15
16
|
return new Response(JSON.stringify(body) ?? "null", {
|
|
16
17
|
status,
|
|
17
|
-
headers: JSON_HEADERS,
|
|
18
|
+
headers: headers ? { ...JSON_HEADERS, ...headers } : JSON_HEADERS,
|
|
18
19
|
});
|
|
19
20
|
}
|
|
20
21
|
|
|
22
|
+
/**
|
|
23
|
+
* Diagnostic response headers for translated inference requests, mirroring the
|
|
24
|
+
* names existing gateway-aware clients already parse: `x-request-id` /
|
|
25
|
+
* `request-id` (surfaced as `_request_id` by the OpenAI and Anthropic SDKs,
|
|
26
|
+
* matches the gateway log line), LiteLLM's model-resolution and cost headers,
|
|
27
|
+
* and OpenAI's `openai-processing-ms`. Model/request-id headers are always
|
|
28
|
+
* present; `message` — the final assistant message, available only on
|
|
29
|
+
* non-streaming responses — adds the computed cost, and `startedAt` the wall
|
|
30
|
+
* time. Streaming responses send headers before usage exists, so they carry
|
|
31
|
+
* only the identity headers.
|
|
32
|
+
*/
|
|
33
|
+
export function gatewayResponseHeaders(
|
|
34
|
+
model: Model<Api>,
|
|
35
|
+
info: { requestId: string; message?: AssistantMessage; startedAt?: number },
|
|
36
|
+
): Record<string, string> {
|
|
37
|
+
const headers: Record<string, string> = {
|
|
38
|
+
"x-request-id": info.requestId,
|
|
39
|
+
"request-id": info.requestId,
|
|
40
|
+
"x-litellm-model-id": model.id,
|
|
41
|
+
};
|
|
42
|
+
if (model.baseUrl) headers["x-litellm-model-api-base"] = model.baseUrl;
|
|
43
|
+
if (info.message) headers["x-litellm-response-cost"] = info.message.usage.cost.total.toString();
|
|
44
|
+
if (info.startedAt !== undefined) {
|
|
45
|
+
const elapsed = (performance.now() - info.startedAt).toFixed(0);
|
|
46
|
+
headers["x-litellm-response-duration-ms"] = elapsed;
|
|
47
|
+
headers["openai-processing-ms"] = elapsed;
|
|
48
|
+
}
|
|
49
|
+
return headers;
|
|
50
|
+
}
|
|
51
|
+
|
|
21
52
|
export function resolvePeer(req: Request): string {
|
|
22
53
|
const fwd = req.headers.get("x-forwarded-for");
|
|
23
54
|
if (fwd) return fwd.split(",")[0].trim();
|
|
@@ -165,6 +196,8 @@ const CORS_HEADERS: Record<string, string> = {
|
|
|
165
196
|
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
|
|
166
197
|
"Access-Control-Allow-Headers":
|
|
167
198
|
"authorization, content-type, anthropic-version, anthropic-beta, openai-organization, openai-project, x-stainless-*, x-api-key",
|
|
199
|
+
"Access-Control-Expose-Headers":
|
|
200
|
+
"x-request-id, request-id, x-litellm-model-id, x-litellm-model-api-base, x-litellm-response-cost, x-litellm-response-duration-ms, openai-processing-ms",
|
|
168
201
|
"Access-Control-Max-Age": "86400",
|
|
169
202
|
};
|
|
170
203
|
|
|
@@ -22,6 +22,7 @@ import { Effort } from "@oh-my-pi/pi-catalog/effort";
|
|
|
22
22
|
import { extractHttpStatusFromError, extractRetryHint, logger } from "@oh-my-pi/pi-utils";
|
|
23
23
|
import type { ApiKeyResolver } from "../auth-retry";
|
|
24
24
|
import type { AuthStorage } from "../auth-storage";
|
|
25
|
+
import * as AIError from "../error";
|
|
25
26
|
import { classifyGatewayError } from "../error/gateway";
|
|
26
27
|
import { isUsageLimitOutcome } from "../error/rate-limit";
|
|
27
28
|
import * as anthropicMessages from "../providers/anthropic-messages-server";
|
|
@@ -32,7 +33,15 @@ import { completeSimple, streamSimple } from "../stream";
|
|
|
32
33
|
import type { Api, AssistantMessageEventStream, Context, Model, SimpleStreamOptions } from "../types";
|
|
33
34
|
import { deterministicUuid } from "../utils/deterministic-id";
|
|
34
35
|
import { parseBind } from "../utils/parse-bind";
|
|
35
|
-
import {
|
|
36
|
+
import {
|
|
37
|
+
captureRequestHeaders,
|
|
38
|
+
corsHeaders,
|
|
39
|
+
gatewayResponseHeaders,
|
|
40
|
+
isAuthorized,
|
|
41
|
+
json,
|
|
42
|
+
resolvePeer,
|
|
43
|
+
withCors,
|
|
44
|
+
} from "./http";
|
|
36
45
|
import type {
|
|
37
46
|
AuthGatewayServerHandle,
|
|
38
47
|
AuthGatewayServerOptions,
|
|
@@ -227,7 +236,8 @@ async function refreshGatewayApiKeyAfterAuthError(
|
|
|
227
236
|
peer: string,
|
|
228
237
|
): Promise<string | undefined> {
|
|
229
238
|
const message = error instanceof Error ? error.message : String(error);
|
|
230
|
-
|
|
239
|
+
const status = extractHttpStatusFromError(error);
|
|
240
|
+
if (AIError.isUsageLimit(error) || isUsageLimitOutcome(status, message)) {
|
|
231
241
|
const retryAfterMs = extractRetryHint(undefined, message);
|
|
232
242
|
const { switched, retryAtMs } = await storage.markUsageLimitReached(provider, sessionId, {
|
|
233
243
|
retryAfterMs,
|
|
@@ -334,6 +344,8 @@ async function handleFormatEndpoint(
|
|
|
334
344
|
req: Request,
|
|
335
345
|
peer: string,
|
|
336
346
|
): Promise<Response> {
|
|
347
|
+
const startedAt = performance.now();
|
|
348
|
+
const requestId = crypto.randomUUID();
|
|
337
349
|
const controller = mirrorRequestAbort(req);
|
|
338
350
|
if (controller.signal.aborted) return clientClosedResponse(route);
|
|
339
351
|
|
|
@@ -430,6 +442,7 @@ async function handleFormatEndpoint(
|
|
|
430
442
|
);
|
|
431
443
|
|
|
432
444
|
logger.info("auth-gateway request", {
|
|
445
|
+
requestId,
|
|
433
446
|
format: route.label,
|
|
434
447
|
model: parsed.modelId,
|
|
435
448
|
resolvedProvider: model.provider,
|
|
@@ -458,7 +471,11 @@ async function handleFormatEndpoint(
|
|
|
458
471
|
const classified = classifyGatewayError(errorMessage);
|
|
459
472
|
return route.module.formatError(classified.status, classified.type, errorMessage);
|
|
460
473
|
}
|
|
461
|
-
return json(
|
|
474
|
+
return json(
|
|
475
|
+
200,
|
|
476
|
+
route.module.encodeResponse(message, parsed.modelId),
|
|
477
|
+
gatewayResponseHeaders(model, { requestId, message, startedAt }),
|
|
478
|
+
);
|
|
462
479
|
} catch (error) {
|
|
463
480
|
if (controller.signal.aborted) return clientClosedResponse(route);
|
|
464
481
|
const classified = classifyGatewayError(error);
|
|
@@ -493,6 +510,7 @@ async function handleFormatEndpoint(
|
|
|
493
510
|
return new Response(sseStream, {
|
|
494
511
|
status: 200,
|
|
495
512
|
headers: {
|
|
513
|
+
...gatewayResponseHeaders(model, { requestId }),
|
|
496
514
|
"Content-Type": "text/event-stream; charset=utf-8",
|
|
497
515
|
"Cache-Control": "no-cache",
|
|
498
516
|
Connection: "keep-alive",
|
|
@@ -519,6 +537,8 @@ async function handleFormatEndpoint(
|
|
|
519
537
|
* path.
|
|
520
538
|
*/
|
|
521
539
|
async function handlePiNative(bootOpts: AuthGatewayBootOptions, req: Request, peer: string): Promise<Response> {
|
|
540
|
+
const startedAt = performance.now();
|
|
541
|
+
const requestId = crypto.randomUUID();
|
|
522
542
|
const controller = mirrorRequestAbort(req);
|
|
523
543
|
const aborted = (): Response => piNative.formatError(499, "request_aborted", "client closed request");
|
|
524
544
|
if (controller.signal.aborted) return aborted();
|
|
@@ -605,6 +625,7 @@ async function handlePiNative(bootOpts: AuthGatewayBootOptions, req: Request, pe
|
|
|
605
625
|
streamOpts.sessionId ??= sessionId;
|
|
606
626
|
|
|
607
627
|
logger.info("auth-gateway request", {
|
|
628
|
+
requestId,
|
|
608
629
|
format: "pi-native",
|
|
609
630
|
model: parsed.modelId,
|
|
610
631
|
resolvedProvider: model.provider,
|
|
@@ -633,7 +654,7 @@ async function handlePiNative(bootOpts: AuthGatewayBootOptions, req: Request, pe
|
|
|
633
654
|
const classified = classifyGatewayError(errorMessage);
|
|
634
655
|
return piNative.formatError(classified.status, classified.type, errorMessage);
|
|
635
656
|
}
|
|
636
|
-
return json(200, { message });
|
|
657
|
+
return json(200, { message }, gatewayResponseHeaders(model, { requestId, message, startedAt }));
|
|
637
658
|
} catch (error) {
|
|
638
659
|
if (controller.signal.aborted) return aborted();
|
|
639
660
|
const classified = classifyGatewayError(error);
|
|
@@ -664,6 +685,7 @@ async function handlePiNative(bootOpts: AuthGatewayBootOptions, req: Request, pe
|
|
|
664
685
|
return new Response(sseStream, {
|
|
665
686
|
status: 200,
|
|
666
687
|
headers: {
|
|
688
|
+
...gatewayResponseHeaders(model, { requestId }),
|
|
667
689
|
"Content-Type": "text/event-stream; charset=utf-8",
|
|
668
690
|
"Cache-Control": "no-cache",
|
|
669
691
|
Connection: "keep-alive",
|