@oh-my-pi/pi-ai 18.2.7 → 18.2.9
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 +49 -18
- package/dist/types/auth-gateway/dispatch.d.ts +80 -0
- package/dist/types/auth-gateway/http.d.ts +5 -6
- package/dist/types/auth-gateway/index.d.ts +1 -0
- package/dist/types/auth-gateway/routes/embeddings.d.ts +3 -0
- package/dist/types/auth-gateway/routes/images.d.ts +3 -0
- package/dist/types/auth-gateway/routes/rerank.d.ts +3 -0
- package/dist/types/auth-gateway/routes/speech.d.ts +2 -0
- package/dist/types/auth-gateway/routes/systemone.d.ts +2 -0
- package/dist/types/auth-gateway/routes/transcriptions.d.ts +3 -0
- package/dist/types/auth-gateway/routes/video.d.ts +7 -0
- package/dist/types/auth-gateway/server.d.ts +10 -16
- package/dist/types/auth-gateway/types.d.ts +5 -0
- package/dist/types/auth-storage.d.ts +35 -32
- package/dist/types/embeddings/index.d.ts +7 -0
- package/dist/types/embeddings/openai-embeddings.d.ts +15 -0
- package/dist/types/embeddings/types.d.ts +15 -0
- package/dist/types/images/google-antigravity.d.ts +9 -0
- package/dist/types/images/google-generative-ai.d.ts +3 -0
- package/dist/types/images/index.d.ts +14 -0
- package/dist/types/images/openai-hosted.d.ts +3 -0
- package/dist/types/images/openai-images.d.ts +5 -0
- package/dist/types/images/openrouter-images.d.ts +3 -0
- package/dist/types/images/shared.d.ts +34 -0
- package/dist/types/images/types.d.ts +31 -0
- package/dist/types/index.d.ts +8 -1
- package/dist/types/judgment/typesafe.d.ts +2 -0
- package/dist/types/providers/amazon-bedrock.d.ts +7 -0
- package/dist/types/providers/claude-code-fingerprint.d.ts +22 -3
- package/dist/types/providers/embeddings-server.d.ts +32 -0
- package/dist/types/providers/google-gemini-cli.d.ts +0 -2
- package/dist/types/providers/images-server.d.ts +22 -0
- package/dist/types/providers/openai-chat-server-schema.d.ts +2 -2
- package/dist/types/providers/rerank-server.d.ts +35 -0
- package/dist/types/providers/speech-server.d.ts +8 -0
- package/dist/types/providers/systemone-server.d.ts +26 -0
- package/dist/types/providers/transcriptions-server.d.ts +32 -0
- package/dist/types/providers/video-server.d.ts +39 -0
- package/dist/types/rerank/index.d.ts +7 -0
- package/dist/types/rerank/openrouter-rerank.d.ts +15 -0
- package/dist/types/rerank/types.d.ts +17 -0
- package/dist/types/speech/index.d.ts +13 -0
- package/dist/types/speech/openai-speech.d.ts +3 -0
- package/dist/types/speech/transport.d.ts +7 -0
- package/dist/types/speech/types.d.ts +24 -0
- package/dist/types/speech/xai-tts.d.ts +7 -0
- package/dist/types/transcription/index.d.ts +7 -0
- package/dist/types/transcription/openai-transcriptions.d.ts +15 -0
- package/dist/types/transcription/types.d.ts +41 -0
- package/dist/types/usage/claude-api.d.ts +22 -0
- package/dist/types/usage/claude-reset.d.ts +44 -0
- package/dist/types/usage.d.ts +111 -5
- package/dist/types/utils/schema/json-schema-validator.d.ts +5 -2
- package/dist/types/utils/tool-call-loop-guard.d.ts +1 -1
- package/dist/types/video/index.d.ts +11 -0
- package/dist/types/video/openrouter-video.d.ts +19 -0
- package/dist/types/video/types.d.ts +62 -0
- package/package.json +30 -6
- package/src/auth/sqlite-credential-store.ts +44 -1
- package/src/auth-broker/remote-store.ts +6 -6
- package/src/auth-broker/wire-schemas.ts +14 -0
- package/src/auth-gateway/dispatch.ts +273 -0
- package/src/auth-gateway/http.ts +6 -7
- package/src/auth-gateway/index.ts +1 -0
- package/src/auth-gateway/routes/embeddings.ts +98 -0
- package/src/auth-gateway/routes/images.ts +131 -0
- package/src/auth-gateway/routes/rerank.ts +87 -0
- package/src/auth-gateway/routes/speech.ts +101 -0
- package/src/auth-gateway/routes/systemone.ts +116 -0
- package/src/auth-gateway/routes/transcriptions.ts +98 -0
- package/src/auth-gateway/routes/video.ts +243 -0
- package/src/auth-gateway/server.ts +127 -260
- package/src/auth-gateway/types.ts +5 -0
- package/src/auth-storage.ts +263 -140
- package/src/embeddings/index.ts +17 -0
- package/src/embeddings/openai-embeddings.ts +141 -0
- package/src/embeddings/types.ts +14 -0
- package/src/error/flags.ts +10 -0
- package/src/error/rate-limit.ts +1 -1
- package/src/images/google-antigravity.ts +180 -0
- package/src/images/google-generative-ai.ts +92 -0
- package/src/images/index.ts +59 -0
- package/src/images/openai-hosted.ts +185 -0
- package/src/images/openai-images.ts +110 -0
- package/src/images/openrouter-images.ts +33 -0
- package/src/images/shared.ts +193 -0
- package/src/images/types.ts +36 -0
- package/src/index.ts +8 -1
- package/src/judgment/typesafe.ts +5 -0
- package/src/providers/amazon-bedrock.ts +55 -5
- package/src/providers/anthropic.ts +45 -11
- package/src/providers/aws-credentials.ts +124 -11
- package/src/providers/claude-code-fingerprint.ts +55 -3
- package/src/providers/embeddings-server.ts +151 -0
- package/src/providers/gitlab-duo.ts +20 -4
- package/src/providers/google-gemini-cli.ts +0 -8
- package/src/providers/google-shared.ts +1 -18
- package/src/providers/images-server.ts +159 -0
- package/src/providers/openai-chat-server-schema.ts +1 -1
- package/src/providers/openai-chat-server.ts +3 -1
- package/src/providers/openai-codex-responses.ts +27 -5
- package/src/providers/openai-completions.ts +122 -19
- package/src/providers/pi-native-server.ts +1 -0
- package/src/providers/rerank-server.ts +166 -0
- package/src/providers/speech-server.ts +53 -0
- package/src/providers/systemone-server.ts +73 -0
- package/src/providers/transcriptions-server.ts +243 -0
- package/src/providers/video-server.ts +286 -0
- package/src/registry/oauth/anthropic.ts +2 -3
- package/src/rerank/index.ts +13 -0
- package/src/rerank/openrouter-rerank.ts +136 -0
- package/src/rerank/types.ts +20 -0
- package/src/speech/index.ts +35 -0
- package/src/speech/openai-speech.ts +26 -0
- package/src/speech/transport.ts +66 -0
- package/src/speech/types.ts +37 -0
- package/src/speech/xai-tts.ts +41 -0
- package/src/stream.ts +13 -4
- package/src/transcription/index.ts +17 -0
- package/src/transcription/openai-transcriptions.ts +133 -0
- package/src/transcription/types.ts +46 -0
- package/src/usage/alibaba-token-plan.ts +7 -1
- package/src/usage/claude-api.ts +66 -0
- package/src/usage/claude-reset.ts +638 -0
- package/src/usage/claude.ts +37 -59
- package/src/usage/kimi.ts +32 -1
- package/src/usage.ts +52 -5
- package/src/utils/schema/json-schema-validator.ts +23 -10
- package/src/utils/tool-call-loop-guard.ts +2 -2
- package/src/utils/validation.ts +145 -50
- package/src/video/index.ts +34 -0
- package/src/video/openrouter-video.ts +210 -0
- package/src/video/types.ts +72 -0
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { logger } from "@oh-my-pi/pi-utils";
|
|
2
|
+
import { classifyGatewayError } from "../../error/gateway";
|
|
3
|
+
import * as speechWire from "../../providers/speech-server";
|
|
4
|
+
import { isSpeechApi, synthesizeSpeech } from "../../speech";
|
|
5
|
+
import { deterministicUuid } from "../../utils/deterministic-id";
|
|
6
|
+
import {
|
|
7
|
+
type AuthGatewayBootOptions,
|
|
8
|
+
buildGatewayApiKeyResolver,
|
|
9
|
+
mirrorRequestAbort,
|
|
10
|
+
recordGatewayUsage,
|
|
11
|
+
resolveGatewayApiKey,
|
|
12
|
+
} from "../dispatch";
|
|
13
|
+
import { gatewayResponseHeaders, resolveClientIdentity } from "../http";
|
|
14
|
+
|
|
15
|
+
export async function handleSpeech(bootOpts: AuthGatewayBootOptions, req: Request, peer: string): Promise<Response> {
|
|
16
|
+
const startedAt = performance.now();
|
|
17
|
+
const requestId = crypto.randomUUID();
|
|
18
|
+
const controller = mirrorRequestAbort(req);
|
|
19
|
+
const aborted = (): Response => speechWire.formatError(499, "request_aborted", "client closed request");
|
|
20
|
+
if (controller.signal.aborted) return aborted();
|
|
21
|
+
|
|
22
|
+
let body: unknown;
|
|
23
|
+
try {
|
|
24
|
+
body = await req.json();
|
|
25
|
+
} catch (error) {
|
|
26
|
+
if (controller.signal.aborted) return aborted();
|
|
27
|
+
return speechWire.formatError(400, "invalid_request_error", `Invalid JSON body: ${String(error)}`);
|
|
28
|
+
}
|
|
29
|
+
if (controller.signal.aborted) return aborted();
|
|
30
|
+
|
|
31
|
+
let parsed: speechWire.SpeechParsedRequest;
|
|
32
|
+
try {
|
|
33
|
+
parsed = speechWire.parseRequest(body, req.headers);
|
|
34
|
+
} catch (error) {
|
|
35
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
36
|
+
return speechWire.formatError(400, "invalid_request_error", message);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const model = bootOpts.resolveModel(parsed.modelId);
|
|
40
|
+
if (!model) return speechWire.formatError(404, "invalid_request_error", `Unknown model: ${parsed.modelId}`);
|
|
41
|
+
if (!isSpeechApi(model.api)) {
|
|
42
|
+
const route =
|
|
43
|
+
model.kind === "image"
|
|
44
|
+
? "/v1/images/generations"
|
|
45
|
+
: model.kind === "stt"
|
|
46
|
+
? "/v1/audio/transcriptions"
|
|
47
|
+
: model.kind === "judge"
|
|
48
|
+
? "/v1/systemone"
|
|
49
|
+
: undefined;
|
|
50
|
+
const message = route
|
|
51
|
+
? `Model ${parsed.modelId} does not synthesize speech; use POST ${route}`
|
|
52
|
+
: `Model ${parsed.modelId} does not synthesize speech`;
|
|
53
|
+
return speechWire.formatError(400, "invalid_request_error", message);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const client = resolveClientIdentity(req.headers);
|
|
57
|
+
const sessionId = deterministicUuid(`speech\u0000${model.provider}/${model.id}`);
|
|
58
|
+
const apiKey = await resolveGatewayApiKey(bootOpts.storage, model, sessionId, controller.signal, peer);
|
|
59
|
+
if (controller.signal.aborted) return aborted();
|
|
60
|
+
if (typeof apiKey !== "string") return speechWire.formatError(apiKey.status, apiKey.type, apiKey.message);
|
|
61
|
+
|
|
62
|
+
logger.info("auth-gateway request", {
|
|
63
|
+
requestId,
|
|
64
|
+
format: "speech",
|
|
65
|
+
model: parsed.modelId,
|
|
66
|
+
resolvedProvider: model.provider,
|
|
67
|
+
resolvedModel: model.id,
|
|
68
|
+
stream: false,
|
|
69
|
+
peer,
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
try {
|
|
73
|
+
const result = await synthesizeSpeech(model, parsed.request, {
|
|
74
|
+
apiKey: buildGatewayApiKeyResolver(
|
|
75
|
+
bootOpts.storage,
|
|
76
|
+
model,
|
|
77
|
+
sessionId,
|
|
78
|
+
apiKey,
|
|
79
|
+
controller.signal,
|
|
80
|
+
"speech",
|
|
81
|
+
peer,
|
|
82
|
+
),
|
|
83
|
+
fetch: bootOpts.fetch,
|
|
84
|
+
signal: controller.signal,
|
|
85
|
+
});
|
|
86
|
+
recordGatewayUsage(bootOpts.storage, model, client, result.usage);
|
|
87
|
+
const response = speechWire.encodeResponse(result, parsed.modelId);
|
|
88
|
+
const responseHeaders = gatewayResponseHeaders(model, {
|
|
89
|
+
requestId,
|
|
90
|
+
costUsd: result.usage.cost.total,
|
|
91
|
+
startedAt,
|
|
92
|
+
});
|
|
93
|
+
for (const name in responseHeaders) response.headers.set(name, responseHeaders[name]);
|
|
94
|
+
return response;
|
|
95
|
+
} catch (error) {
|
|
96
|
+
if (controller.signal.aborted) return aborted();
|
|
97
|
+
const classified = classifyGatewayError(error);
|
|
98
|
+
logger.warn("auth-gateway speech failed", { format: "speech", error: classified.message, peer });
|
|
99
|
+
return speechWire.formatError(classified.status, classified.type, classified.message);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TypeSafe System One judgments: `POST /v1/systemone` (TypeSafe's path) and
|
|
3
|
+
* `POST /alpha/decisions` (OpenRouter's Decisions path, same wire).
|
|
4
|
+
*
|
|
5
|
+
* Same bearer auth, model resolution, broker credential lookup, and a/b/c
|
|
6
|
+
* credential rotation as the chat routes, dispatched through
|
|
7
|
+
* {@link TypeSafeJudge} — pi-ai's client for this wire — so 401 rotation and
|
|
8
|
+
* 429/529 backoff match omp's own judgments. A judgment is one JSON
|
|
9
|
+
* round-trip: no stream, no provider session state. TypeSafe reports tokens
|
|
10
|
+
* only, so the cost is priced from the catalog model for the response header
|
|
11
|
+
* and the broker's observed-usage ledger.
|
|
12
|
+
*/
|
|
13
|
+
import { calculateCost } from "@oh-my-pi/pi-catalog/models";
|
|
14
|
+
import { logger } from "@oh-my-pi/pi-utils";
|
|
15
|
+
import { classifyGatewayError } from "../../error/gateway";
|
|
16
|
+
import { isJudgmentApi, TypeSafeJudge } from "../../judgment/typesafe";
|
|
17
|
+
import * as systemOne from "../../providers/systemone-server";
|
|
18
|
+
import { deterministicUuid } from "../../utils/deterministic-id";
|
|
19
|
+
import {
|
|
20
|
+
type AuthGatewayBootOptions,
|
|
21
|
+
buildGatewayApiKeyResolver,
|
|
22
|
+
mirrorRequestAbort,
|
|
23
|
+
recordGatewayUsage,
|
|
24
|
+
resolveGatewayApiKey,
|
|
25
|
+
} from "../dispatch";
|
|
26
|
+
import { gatewayResponseHeaders, json, resolveClientIdentity } from "../http";
|
|
27
|
+
|
|
28
|
+
export async function handleSystemOne(bootOpts: AuthGatewayBootOptions, req: Request, peer: string): Promise<Response> {
|
|
29
|
+
const startedAt = performance.now();
|
|
30
|
+
const requestId = crypto.randomUUID();
|
|
31
|
+
const controller = mirrorRequestAbort(req);
|
|
32
|
+
const aborted = (): Response => systemOne.formatError(499, "request_aborted", "client closed request");
|
|
33
|
+
if (controller.signal.aborted) return aborted();
|
|
34
|
+
|
|
35
|
+
let body: unknown;
|
|
36
|
+
try {
|
|
37
|
+
body = await req.json();
|
|
38
|
+
} catch (error) {
|
|
39
|
+
if (controller.signal.aborted) return aborted();
|
|
40
|
+
return systemOne.formatError(400, "invalid_request_error", `Invalid JSON body: ${String(error)}`);
|
|
41
|
+
}
|
|
42
|
+
if (controller.signal.aborted) return aborted();
|
|
43
|
+
|
|
44
|
+
let parsed: systemOne.SystemOneParsedRequest;
|
|
45
|
+
try {
|
|
46
|
+
parsed = systemOne.parseRequest(body);
|
|
47
|
+
} catch (error) {
|
|
48
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
49
|
+
// TypeSafe answers body validation failures with 422.
|
|
50
|
+
return systemOne.formatError(422, "invalid_request_error", message);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const model = bootOpts.resolveModel(parsed.modelId);
|
|
54
|
+
if (!model) {
|
|
55
|
+
return systemOne.formatError(404, "invalid_request_error", `Unknown model: ${parsed.modelId}`);
|
|
56
|
+
}
|
|
57
|
+
if (!isJudgmentApi(model.api)) {
|
|
58
|
+
return systemOne.formatError(
|
|
59
|
+
422,
|
|
60
|
+
"invalid_request_error",
|
|
61
|
+
`Model ${parsed.modelId} does not answer System One judgments`,
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
const client = resolveClientIdentity(req.headers);
|
|
65
|
+
// Judgments carry no conversation to derive a session from. One sticky
|
|
66
|
+
// session per model keeps credential selection stable across calls and
|
|
67
|
+
// still lets `markUsageLimitReached` hand off to a sibling account.
|
|
68
|
+
const sessionId = deterministicUuid(`systemone\u0000${model.provider}/${model.id}`);
|
|
69
|
+
|
|
70
|
+
const apiKey = await resolveGatewayApiKey(bootOpts.storage, model, sessionId, controller.signal, peer);
|
|
71
|
+
if (controller.signal.aborted) return aborted();
|
|
72
|
+
if (typeof apiKey !== "string") return systemOne.formatError(apiKey.status, apiKey.type, apiKey.message);
|
|
73
|
+
|
|
74
|
+
const judge = new TypeSafeJudge({
|
|
75
|
+
apiKey: buildGatewayApiKeyResolver(
|
|
76
|
+
bootOpts.storage,
|
|
77
|
+
model,
|
|
78
|
+
sessionId,
|
|
79
|
+
apiKey,
|
|
80
|
+
controller.signal,
|
|
81
|
+
"systemone",
|
|
82
|
+
peer,
|
|
83
|
+
),
|
|
84
|
+
api: model.api,
|
|
85
|
+
provider: model.provider,
|
|
86
|
+
model: model.id,
|
|
87
|
+
baseUrl: model.baseUrl,
|
|
88
|
+
fetch: bootOpts.fetch,
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
logger.info("auth-gateway request", {
|
|
92
|
+
requestId,
|
|
93
|
+
format: "systemone",
|
|
94
|
+
model: parsed.modelId,
|
|
95
|
+
resolvedProvider: model.provider,
|
|
96
|
+
resolvedModel: model.id,
|
|
97
|
+
stream: false,
|
|
98
|
+
peer,
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
try {
|
|
102
|
+
const result = await judge.judge(parsed.request, { signal: controller.signal });
|
|
103
|
+
// Encode before pricing: the body's `cost` means "upstream billed this"
|
|
104
|
+
// (OpenRouter), while the header and ledger carry the catalog estimate
|
|
105
|
+
// when the upstream reported tokens only (TypeSafe).
|
|
106
|
+
const body = systemOne.encodeResponse(result);
|
|
107
|
+
if (result.usage.cost.total === 0) calculateCost(model, result.usage);
|
|
108
|
+
recordGatewayUsage(bootOpts.storage, model, client, result.usage);
|
|
109
|
+
return json(200, body, gatewayResponseHeaders(model, { requestId, costUsd: result.usage.cost.total, startedAt }));
|
|
110
|
+
} catch (error) {
|
|
111
|
+
if (controller.signal.aborted) return aborted();
|
|
112
|
+
const classified = classifyGatewayError(error);
|
|
113
|
+
logger.warn("auth-gateway judgment failed", { format: "systemone", error: classified.message, peer });
|
|
114
|
+
return systemOne.formatError(classified.status, classified.type, classified.message);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { logger } from "@oh-my-pi/pi-utils";
|
|
2
|
+
import { classifyGatewayError } from "../../error/gateway";
|
|
3
|
+
import * as transcriptions from "../../providers/transcriptions-server";
|
|
4
|
+
import { transcribeAudio } from "../../transcription";
|
|
5
|
+
import { deterministicUuid } from "../../utils/deterministic-id";
|
|
6
|
+
import {
|
|
7
|
+
type AuthGatewayBootOptions,
|
|
8
|
+
buildGatewayApiKeyResolver,
|
|
9
|
+
mirrorRequestAbort,
|
|
10
|
+
recordGatewayUsage,
|
|
11
|
+
resolveGatewayApiKey,
|
|
12
|
+
} from "../dispatch";
|
|
13
|
+
import { gatewayResponseHeaders, json, resolveClientIdentity } from "../http";
|
|
14
|
+
|
|
15
|
+
/** OpenAI-compatible `POST /v1/audio/transcriptions` gateway handler. */
|
|
16
|
+
export async function handleTranscriptions(
|
|
17
|
+
bootOpts: AuthGatewayBootOptions,
|
|
18
|
+
req: Request,
|
|
19
|
+
peer: string,
|
|
20
|
+
): Promise<Response> {
|
|
21
|
+
const startedAt = performance.now();
|
|
22
|
+
const requestId = crypto.randomUUID();
|
|
23
|
+
const controller = mirrorRequestAbort(req);
|
|
24
|
+
const aborted = (): Response => transcriptions.formatError(499, "request_aborted", "client closed request");
|
|
25
|
+
if (controller.signal.aborted) return aborted();
|
|
26
|
+
|
|
27
|
+
let parsed: transcriptions.TranscriptionParsedRequest;
|
|
28
|
+
try {
|
|
29
|
+
parsed = await transcriptions.parseRequest(req);
|
|
30
|
+
} catch (error) {
|
|
31
|
+
if (controller.signal.aborted) return aborted();
|
|
32
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
33
|
+
const status = error instanceof transcriptions.TranscriptionWireError ? error.status : 400;
|
|
34
|
+
return transcriptions.formatError(status, "invalid_request_error", message);
|
|
35
|
+
}
|
|
36
|
+
if (controller.signal.aborted) return aborted();
|
|
37
|
+
|
|
38
|
+
const model = bootOpts.resolveModel(parsed.modelId);
|
|
39
|
+
if (!model) {
|
|
40
|
+
return transcriptions.formatError(404, "invalid_request_error", `Unknown model: ${parsed.modelId}`);
|
|
41
|
+
}
|
|
42
|
+
if (model.api !== "openai-transcriptions") {
|
|
43
|
+
const detail =
|
|
44
|
+
model.api === "local-inference"
|
|
45
|
+
? `Model ${parsed.modelId} runs on-device only and cannot be served by the auth gateway`
|
|
46
|
+
: `Model ${parsed.modelId} does not support audio transcription`;
|
|
47
|
+
return transcriptions.formatError(400, "invalid_request_error", detail);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const client = resolveClientIdentity(req.headers);
|
|
51
|
+
const sessionId = deterministicUuid(`transcriptions\u0000${model.provider}/${model.id}`);
|
|
52
|
+
const apiKey = await resolveGatewayApiKey(bootOpts.storage, model, sessionId, controller.signal, peer);
|
|
53
|
+
if (controller.signal.aborted) return aborted();
|
|
54
|
+
if (typeof apiKey !== "string") {
|
|
55
|
+
return transcriptions.formatError(apiKey.status, apiKey.type, apiKey.message);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
logger.info("auth-gateway request", {
|
|
59
|
+
requestId,
|
|
60
|
+
format: "transcriptions",
|
|
61
|
+
model: parsed.modelId,
|
|
62
|
+
resolvedProvider: model.provider,
|
|
63
|
+
resolvedModel: model.id,
|
|
64
|
+
stream: false,
|
|
65
|
+
peer,
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
try {
|
|
69
|
+
const result = await transcribeAudio(model, parsed.request, {
|
|
70
|
+
apiKey: buildGatewayApiKeyResolver(
|
|
71
|
+
bootOpts.storage,
|
|
72
|
+
model,
|
|
73
|
+
sessionId,
|
|
74
|
+
apiKey,
|
|
75
|
+
controller.signal,
|
|
76
|
+
"transcriptions",
|
|
77
|
+
peer,
|
|
78
|
+
),
|
|
79
|
+
fetch: bootOpts.fetch,
|
|
80
|
+
signal: controller.signal,
|
|
81
|
+
});
|
|
82
|
+
recordGatewayUsage(bootOpts.storage, model, client, result.usage);
|
|
83
|
+
return json(
|
|
84
|
+
200,
|
|
85
|
+
transcriptions.encodeResponse(result),
|
|
86
|
+
gatewayResponseHeaders(model, { requestId, costUsd: result.usage.cost.total, startedAt }),
|
|
87
|
+
);
|
|
88
|
+
} catch (error) {
|
|
89
|
+
if (controller.signal.aborted) return aborted();
|
|
90
|
+
const classified = classifyGatewayError(error);
|
|
91
|
+
logger.warn("auth-gateway transcription failed", {
|
|
92
|
+
format: "transcriptions",
|
|
93
|
+
error: classified.message,
|
|
94
|
+
peer,
|
|
95
|
+
});
|
|
96
|
+
return transcriptions.formatError(classified.status, classified.type, classified.message);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import type { Api, Model } from "@oh-my-pi/pi-catalog/types";
|
|
2
|
+
import { logger } from "@oh-my-pi/pi-utils";
|
|
3
|
+
import { classifyGatewayError } from "../../error/gateway";
|
|
4
|
+
import * as videoServer from "../../providers/video-server";
|
|
5
|
+
import { downloadVideo, pollVideo, submitVideo } from "../../video";
|
|
6
|
+
import type { VideoJob } from "../../video/types";
|
|
7
|
+
import { deterministicUuid } from "../../utils/deterministic-id";
|
|
8
|
+
import {
|
|
9
|
+
type AuthGatewayBootOptions,
|
|
10
|
+
buildGatewayApiKeyResolver,
|
|
11
|
+
mirrorRequestAbort,
|
|
12
|
+
resolveGatewayApiKey,
|
|
13
|
+
} from "../dispatch";
|
|
14
|
+
import { gatewayResponseHeaders, json, resolveClientIdentity } from "../http";
|
|
15
|
+
|
|
16
|
+
interface ResolvedVideoRequest {
|
|
17
|
+
model: Model<Api>;
|
|
18
|
+
upstreamId: string;
|
|
19
|
+
sessionId: string;
|
|
20
|
+
apiKey: string;
|
|
21
|
+
controller: AbortController;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function aborted(): Response {
|
|
25
|
+
return videoServer.formatError(499, "request_aborted", "client closed request");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function resolveVideoJob(
|
|
29
|
+
bootOpts: AuthGatewayBootOptions,
|
|
30
|
+
req: Request,
|
|
31
|
+
peer: string,
|
|
32
|
+
gatewayId: string,
|
|
33
|
+
): Promise<ResolvedVideoRequest | Response> {
|
|
34
|
+
const controller = mirrorRequestAbort(req);
|
|
35
|
+
if (controller.signal.aborted) return aborted();
|
|
36
|
+
let identity: videoServer.GatewayJobIdentity;
|
|
37
|
+
try {
|
|
38
|
+
identity = videoServer.decodeGatewayJobId(gatewayId);
|
|
39
|
+
} catch (error) {
|
|
40
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
41
|
+
return videoServer.formatError(400, "invalid_request_error", message);
|
|
42
|
+
}
|
|
43
|
+
const model = bootOpts.resolveModel(`${identity.provider}/${identity.modelId}`);
|
|
44
|
+
if (!model) {
|
|
45
|
+
return videoServer.formatError(
|
|
46
|
+
404,
|
|
47
|
+
"invalid_request_error",
|
|
48
|
+
`Unknown model: ${identity.provider}/${identity.modelId}`,
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
if (model.api !== "openrouter-video") {
|
|
52
|
+
return videoServer.formatError(
|
|
53
|
+
400,
|
|
54
|
+
"invalid_request_error",
|
|
55
|
+
`Model ${model.id} does not support video generation`,
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
const sessionId = deterministicUuid(`video\u0000${model.provider}/${model.id}`);
|
|
59
|
+
const apiKey = await resolveGatewayApiKey(bootOpts.storage, model, sessionId, controller.signal, peer);
|
|
60
|
+
if (controller.signal.aborted) return aborted();
|
|
61
|
+
if (typeof apiKey !== "string") return videoServer.formatError(apiKey.status, apiKey.type, apiKey.message);
|
|
62
|
+
return { model, upstreamId: identity.upstreamId, sessionId, apiKey, controller };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function videoOptions(bootOpts: AuthGatewayBootOptions, resolved: ResolvedVideoRequest, peer: string) {
|
|
66
|
+
return {
|
|
67
|
+
apiKey: buildGatewayApiKeyResolver(
|
|
68
|
+
bootOpts.storage,
|
|
69
|
+
resolved.model,
|
|
70
|
+
resolved.sessionId,
|
|
71
|
+
resolved.apiKey,
|
|
72
|
+
resolved.controller.signal,
|
|
73
|
+
"video",
|
|
74
|
+
peer,
|
|
75
|
+
),
|
|
76
|
+
fetch: bootOpts.fetch,
|
|
77
|
+
signal: resolved.controller.signal,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function logVideoRequest(
|
|
82
|
+
requestId: string,
|
|
83
|
+
operation: "submit" | "poll" | "content",
|
|
84
|
+
model: ResolvedVideoRequest["model"],
|
|
85
|
+
peer: string,
|
|
86
|
+
): void {
|
|
87
|
+
logger.info("auth-gateway request", {
|
|
88
|
+
requestId,
|
|
89
|
+
format: `video-${operation}`,
|
|
90
|
+
model: model.id,
|
|
91
|
+
resolvedProvider: model.provider,
|
|
92
|
+
resolvedModel: model.id,
|
|
93
|
+
stream: operation === "content",
|
|
94
|
+
peer,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function recordCompletedUsage(
|
|
99
|
+
bootOpts: AuthGatewayBootOptions,
|
|
100
|
+
resolved: ResolvedVideoRequest,
|
|
101
|
+
req: Request,
|
|
102
|
+
job: VideoJob,
|
|
103
|
+
): void {
|
|
104
|
+
if (job.status !== "completed" || job.usage === undefined) return;
|
|
105
|
+
bootOpts.storage.recordObservedUsage({
|
|
106
|
+
provider: resolved.model.provider,
|
|
107
|
+
model: resolved.model.id,
|
|
108
|
+
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
109
|
+
costUsd: job.usage.cost.total,
|
|
110
|
+
client: resolveClientIdentity(req.headers),
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** OpenRouter-compatible `POST /v1/videos` asynchronous video submit handler. */
|
|
115
|
+
export async function handleVideoSubmit(
|
|
116
|
+
bootOpts: AuthGatewayBootOptions,
|
|
117
|
+
req: Request,
|
|
118
|
+
peer: string,
|
|
119
|
+
): Promise<Response> {
|
|
120
|
+
const startedAt = performance.now();
|
|
121
|
+
const requestId = crypto.randomUUID();
|
|
122
|
+
const controller = mirrorRequestAbort(req);
|
|
123
|
+
if (controller.signal.aborted) return aborted();
|
|
124
|
+
let parsed: videoServer.VideoParsedRequest;
|
|
125
|
+
try {
|
|
126
|
+
parsed = videoServer.parseRequest(await req.json());
|
|
127
|
+
} catch (error) {
|
|
128
|
+
if (controller.signal.aborted) return aborted();
|
|
129
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
130
|
+
return videoServer.formatError(400, "invalid_request_error", message);
|
|
131
|
+
}
|
|
132
|
+
const model = bootOpts.resolveModel(parsed.modelId);
|
|
133
|
+
if (!model) return videoServer.formatError(404, "invalid_request_error", `Unknown model: ${parsed.modelId}`);
|
|
134
|
+
if (model.api !== "openrouter-video") {
|
|
135
|
+
return videoServer.formatError(
|
|
136
|
+
400,
|
|
137
|
+
"invalid_request_error",
|
|
138
|
+
`Model ${parsed.modelId} does not support video generation`,
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
const sessionId = deterministicUuid(`video\u0000${model.provider}/${model.id}`);
|
|
142
|
+
const apiKey = await resolveGatewayApiKey(bootOpts.storage, model, sessionId, controller.signal, peer);
|
|
143
|
+
if (controller.signal.aborted) return aborted();
|
|
144
|
+
if (typeof apiKey !== "string") return videoServer.formatError(apiKey.status, apiKey.type, apiKey.message);
|
|
145
|
+
logger.info("auth-gateway request", {
|
|
146
|
+
requestId,
|
|
147
|
+
format: "video-submit",
|
|
148
|
+
model: parsed.modelId,
|
|
149
|
+
resolvedProvider: model.provider,
|
|
150
|
+
resolvedModel: model.id,
|
|
151
|
+
stream: false,
|
|
152
|
+
peer,
|
|
153
|
+
});
|
|
154
|
+
try {
|
|
155
|
+
const job = await submitVideo(model, parsed.request, {
|
|
156
|
+
apiKey: buildGatewayApiKeyResolver(
|
|
157
|
+
bootOpts.storage,
|
|
158
|
+
model,
|
|
159
|
+
sessionId,
|
|
160
|
+
apiKey,
|
|
161
|
+
controller.signal,
|
|
162
|
+
"video",
|
|
163
|
+
peer,
|
|
164
|
+
),
|
|
165
|
+
fetch: bootOpts.fetch,
|
|
166
|
+
signal: controller.signal,
|
|
167
|
+
});
|
|
168
|
+
const gatewayId = videoServer.encodeGatewayJobId({
|
|
169
|
+
provider: model.provider,
|
|
170
|
+
modelId: model.id,
|
|
171
|
+
upstreamId: job.id,
|
|
172
|
+
});
|
|
173
|
+
return json(
|
|
174
|
+
202,
|
|
175
|
+
videoServer.encodeSubmitResponse(job, req, gatewayId),
|
|
176
|
+
gatewayResponseHeaders(model, { requestId, startedAt }),
|
|
177
|
+
);
|
|
178
|
+
} catch (error) {
|
|
179
|
+
if (controller.signal.aborted) return aborted();
|
|
180
|
+
const classified = classifyGatewayError(error);
|
|
181
|
+
logger.warn("auth-gateway video submit failed", { format: "video-submit", error: classified.message, peer });
|
|
182
|
+
return videoServer.formatError(classified.status, classified.type, classified.message);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** OpenRouter-compatible `GET /v1/videos/:id` asynchronous video poll handler. */
|
|
187
|
+
export async function handleVideoPoll(
|
|
188
|
+
bootOpts: AuthGatewayBootOptions,
|
|
189
|
+
req: Request,
|
|
190
|
+
peer: string,
|
|
191
|
+
gatewayId: string,
|
|
192
|
+
): Promise<Response> {
|
|
193
|
+
const startedAt = performance.now();
|
|
194
|
+
const requestId = crypto.randomUUID();
|
|
195
|
+
const resolved = await resolveVideoJob(bootOpts, req, peer, gatewayId);
|
|
196
|
+
if (resolved instanceof Response) return resolved;
|
|
197
|
+
logVideoRequest(requestId, "poll", resolved.model, peer);
|
|
198
|
+
try {
|
|
199
|
+
const job = await pollVideo(resolved.model, resolved.upstreamId, videoOptions(bootOpts, resolved, peer));
|
|
200
|
+
recordCompletedUsage(bootOpts, resolved, req, job);
|
|
201
|
+
return json(
|
|
202
|
+
200,
|
|
203
|
+
videoServer.encodePollResponse(job, req, gatewayId),
|
|
204
|
+
gatewayResponseHeaders(resolved.model, {
|
|
205
|
+
requestId,
|
|
206
|
+
...(job.usage !== undefined && { costUsd: job.usage.cost.total }),
|
|
207
|
+
startedAt,
|
|
208
|
+
}),
|
|
209
|
+
);
|
|
210
|
+
} catch (error) {
|
|
211
|
+
if (resolved.controller.signal.aborted) return aborted();
|
|
212
|
+
const classified = classifyGatewayError(error);
|
|
213
|
+
logger.warn("auth-gateway video poll failed", { format: "video-poll", error: classified.message, peer });
|
|
214
|
+
return videoServer.formatError(classified.status, classified.type, classified.message);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** OpenRouter-compatible `GET /v1/videos/:id/content` streaming video content handler. */
|
|
219
|
+
export async function handleVideoContent(
|
|
220
|
+
bootOpts: AuthGatewayBootOptions,
|
|
221
|
+
req: Request,
|
|
222
|
+
peer: string,
|
|
223
|
+
gatewayId: string,
|
|
224
|
+
): Promise<Response> {
|
|
225
|
+
const startedAt = performance.now();
|
|
226
|
+
const requestId = crypto.randomUUID();
|
|
227
|
+
const resolved = await resolveVideoJob(bootOpts, req, peer, gatewayId);
|
|
228
|
+
if (resolved instanceof Response) return resolved;
|
|
229
|
+
logVideoRequest(requestId, "content", resolved.model, peer);
|
|
230
|
+
try {
|
|
231
|
+
const content = await downloadVideo(resolved.model, resolved.upstreamId, videoOptions(bootOpts, resolved, peer));
|
|
232
|
+
const headers = new Headers(gatewayResponseHeaders(resolved.model, { requestId, startedAt }));
|
|
233
|
+
headers.set("Content-Type", content.contentType);
|
|
234
|
+
headers.set("X-Content-Type-Options", "nosniff");
|
|
235
|
+
if (content.contentLength !== undefined) headers.set("Content-Length", String(content.contentLength));
|
|
236
|
+
return new Response(content.body, { status: 200, headers });
|
|
237
|
+
} catch (error) {
|
|
238
|
+
if (resolved.controller.signal.aborted) return aborted();
|
|
239
|
+
const classified = classifyGatewayError(error);
|
|
240
|
+
logger.warn("auth-gateway video content failed", { format: "video-content", error: classified.message, peer });
|
|
241
|
+
return videoServer.formatError(classified.status, classified.type, classified.message);
|
|
242
|
+
}
|
|
243
|
+
}
|