@pyric/cli 0.1.0-alpha.30.1 → 0.1.0-alpha.30.2
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/dist/assurance/.generated/conformance-verdicts.d.ts +4 -0
- package/dist/assurance/.generated/conformance-verdicts.d.ts.map +1 -1
- package/dist/assurance/.generated/conformance-verdicts.js +4 -0
- package/dist/assurance/.generated/conformance-verdicts.js.map +1 -1
- package/dist/bridge/server/peer.d.ts +10 -0
- package/dist/bridge/server/peer.d.ts.map +1 -1
- package/dist/bridge/server/peer.js +46 -1
- package/dist/bridge/server/peer.js.map +1 -1
- package/dist/conformance/.generated/can-i-use.d.ts.map +1 -1
- package/dist/conformance/.generated/can-i-use.js +1 -1
- package/dist/conformance/.generated/can-i-use.js.map +1 -1
- package/dist/conformance/.generated/conformance-docs.d.ts.map +1 -1
- package/dist/conformance/.generated/conformance-docs.js +1 -1
- package/dist/conformance/.generated/conformance-docs.js.map +1 -1
- package/dist/serve/ai-diagnostics-relay.d.ts +4 -1
- package/dist/serve/ai-diagnostics-relay.d.ts.map +1 -1
- package/dist/serve/ai-diagnostics-relay.js +3 -0
- package/dist/serve/ai-diagnostics-relay.js.map +1 -1
- package/dist/serve/ai-proxy.d.ts +21 -1
- package/dist/serve/ai-proxy.d.ts.map +1 -1
- package/dist/serve/ai-proxy.js +39 -14
- package/dist/serve/ai-proxy.js.map +1 -1
- package/dist/serve/ai-terminal-blocks.d.ts +6 -4
- package/dist/serve/ai-terminal-blocks.d.ts.map +1 -1
- package/dist/serve/ai-terminal-blocks.js +7 -4
- package/dist/serve/ai-terminal-blocks.js.map +1 -1
- package/dist/serve/ai-terminal-text.d.ts +8 -0
- package/dist/serve/ai-terminal-text.d.ts.map +1 -1
- package/dist/serve/ai-terminal-text.js +10 -0
- package/dist/serve/ai-terminal-text.js.map +1 -1
- package/dist/serve/entries/ai.d.ts.map +1 -1
- package/dist/serve/entries/ai.js +28 -26
- package/dist/serve/entries/ai.js.map +1 -1
- package/dist/serve/gemini-error-redaction.d.ts +6 -0
- package/dist/serve/gemini-error-redaction.d.ts.map +1 -0
- package/dist/serve/gemini-error-redaction.js +61 -0
- package/dist/serve/gemini-error-redaction.js.map +1 -0
- package/dist/serve/gemini-proxy.d.ts +40 -0
- package/dist/serve/gemini-proxy.d.ts.map +1 -0
- package/dist/serve/gemini-proxy.js +105 -0
- package/dist/serve/gemini-proxy.js.map +1 -0
- package/dist/serve/namespace.d.ts +9 -0
- package/dist/serve/namespace.d.ts.map +1 -1
- package/dist/serve/namespace.js +66 -14
- package/dist/serve/namespace.js.map +1 -1
- package/dist/serve/sandbox-session.d.ts +3 -0
- package/dist/serve/sandbox-session.d.ts.map +1 -1
- package/dist/serve/sandbox-session.js +1 -0
- package/dist/serve/sandbox-session.js.map +1 -1
- package/dist/serve/site-ui/docs/ai-compat/index.html +20 -1
- package/dist/serve/site-ui/docs/ai-compat.md +21 -0
- package/dist/serve/site-ui/docs/app-check-compat/index.html +8 -8
- package/dist/serve/site-ui/docs/app-check-compat.md +8 -8
- package/dist/serve/site-ui/docs/build/ai-logic/index.html +19 -5
- package/dist/serve/site-ui/docs/build/ai-logic.md +19 -2
- package/dist/serve/site-ui/docs/index.json +10 -0
- package/dist/serve/site-ui/docs/storage-compat/index.html +2 -2
- package/dist/serve/site-ui/docs/storage-compat.md +2 -2
- package/dist/serve/site-ui/docs/tutorial/index.html +11 -1
- package/dist/serve/site-ui/docs/tutorial.md +12 -1
- package/dist/serve/site-ui/examples/chess/index.html +1 -1
- package/dist/serve/vite-sandbox-generation.d.ts.map +1 -1
- package/dist/serve/vite-sandbox-generation.js +2 -0
- package/dist/serve/vite-sandbox-generation.js.map +1 -1
- package/dist/serve/worker/ai-proxy-paths.d.ts +32 -0
- package/dist/serve/worker/ai-proxy-paths.d.ts.map +1 -0
- package/dist/serve/worker/ai-proxy-paths.js +20 -0
- package/dist/serve/worker/ai-proxy-paths.js.map +1 -0
- package/dist/serve/worker/client/ai.d.ts +3 -1
- package/dist/serve/worker/client/ai.d.ts.map +1 -1
- package/dist/serve/worker/client/ai.js +3 -0
- package/dist/serve/worker/client/ai.js.map +1 -1
- package/dist/serve/worker/entry.js +2 -0
- package/dist/serve/worker/entry.js.map +1 -1
- package/dist/serve/worker/host-ai.d.ts +4 -5
- package/dist/serve/worker/host-ai.d.ts.map +1 -1
- package/dist/serve/worker/host-ai.js +19 -23
- package/dist/serve/worker/host-ai.js.map +1 -1
- package/dist/serve/worker/host-context.d.ts +13 -4
- package/dist/serve/worker/host-context.d.ts.map +1 -1
- package/dist/serve/worker/host-context.js.map +1 -1
- package/dist/serve/worker/inbound-validation/operation-arguments.d.ts.map +1 -1
- package/dist/serve/worker/inbound-validation/operation-arguments.js +11 -0
- package/dist/serve/worker/inbound-validation/operation-arguments.js.map +1 -1
- package/dist/serve/worker/protocol.d.ts +13 -0
- package/dist/serve/worker/protocol.d.ts.map +1 -1
- package/dist/serve/worker/protocol.js.map +1 -1
- package/package.json +4 -4
- package/src/assurance/.generated/conformance-verdicts.ts +4 -0
- package/src/bridge/server/peer.ts +49 -1
- package/src/conformance/.generated/can-i-use.ts +1 -1
- package/src/conformance/.generated/conformance-docs.ts +1 -1
- package/src/serve/ai-diagnostics-relay.ts +6 -1
- package/src/serve/ai-proxy.ts +43 -14
- package/src/serve/ai-terminal-blocks.ts +8 -4
- package/src/serve/ai-terminal-text.ts +11 -0
- package/src/serve/entries/ai.ts +28 -27
- package/src/serve/gemini-error-redaction.ts +60 -0
- package/src/serve/gemini-proxy.ts +140 -0
- package/src/serve/namespace.ts +69 -14
- package/src/serve/sandbox-session.ts +4 -0
- package/src/serve/vite-sandbox-generation.ts +2 -0
- package/src/serve/worker/ai-proxy-paths.ts +35 -0
- package/src/serve/worker/client/ai.ts +6 -1
- package/src/serve/worker/entry.ts +2 -0
- package/src/serve/worker/host-ai.ts +23 -23
- package/src/serve/worker/host-context.ts +13 -3
- package/src/serve/worker/inbound-validation/operation-arguments.ts +11 -0
- package/src/serve/worker/protocol.ts +14 -2
|
@@ -11,7 +11,8 @@ import { capTerminalText } from './ai-terminal-text.js';
|
|
|
11
11
|
* agent driving `pyric dev` in process therefore saw NOTHING.
|
|
12
12
|
*
|
|
13
13
|
* - `request_rejected`: a bad role, empty `contents`, a missing thought
|
|
14
|
-
* signature,
|
|
14
|
+
* signature, a request without a valid App Check token, or an engine
|
|
15
|
+
* that answered an error envelope.
|
|
15
16
|
* - `response_blocked`: the SILENT refusal. A safety / recitation /
|
|
16
17
|
* blocklist filter answers HTTP 200 with an empty candidate, so nothing
|
|
17
18
|
* throws anywhere and the app simply renders nothing.
|
|
@@ -73,6 +74,8 @@ interface AiRejectionRelayPayload {
|
|
|
73
74
|
code?: number;
|
|
74
75
|
/** The rejection reason: production's own message for shape errors. */
|
|
75
76
|
message: string;
|
|
77
|
+
/** Pyric's guidance beside production's message, such as the App Check gate's. */
|
|
78
|
+
hint?: string;
|
|
76
79
|
}
|
|
77
80
|
|
|
78
81
|
/** Shape POSTed for a `response_blocked` event. Carries the wire's own
|
|
@@ -148,6 +151,8 @@ function toRejectionPayload(event: ServiceMutation): AiRejectionRelayPayload | n
|
|
|
148
151
|
payload.engine = detailText(detail, 'engine');
|
|
149
152
|
payload.status = detailText(detail, 'status');
|
|
150
153
|
if (typeof detail.code === 'number') payload.code = detail.code;
|
|
154
|
+
const hint = detailText(detail, 'hint');
|
|
155
|
+
if (hint !== undefined) payload.hint = hint;
|
|
151
156
|
return payload;
|
|
152
157
|
}
|
|
153
158
|
|
package/src/serve/ai-proxy.ts
CHANGED
|
@@ -16,6 +16,8 @@ import type { AiEngineConfigWire } from './worker/protocol.js';
|
|
|
16
16
|
import { permitGuardHost } from '../register/net-guard.js';
|
|
17
17
|
import { FIREBASE_AI_LOGIC_HOST } from '../google-endpoints.js';
|
|
18
18
|
import type { AiMode } from './firebase-module-swap.js';
|
|
19
|
+
import { HOSTED_METHOD_BODY_LIMIT } from './hosted/method-protocol.js';
|
|
20
|
+
import { BODY_TOO_LARGE_CODE, collectRawBody } from '../bridge/server/peer.js';
|
|
19
21
|
|
|
20
22
|
/** The dev server's diagnostics throttle, as this module needs it: the
|
|
21
23
|
* denials route owns the instance, and the proxy shares it so a failing
|
|
@@ -30,6 +32,10 @@ export const AI_PROXY_DEFAULT_UPSTREAM = 'http://localhost:11434/v1';
|
|
|
30
32
|
/** The route the dev server mounts for OpenAI-compatible traffic. */
|
|
31
33
|
export const AI_PROXY_ROUTE = '/__pyric/ai-proxy';
|
|
32
34
|
|
|
35
|
+
/** Largest request body the proxy forwards: the hosted method route's limit,
|
|
36
|
+
* which already carries AI requests with inline images. */
|
|
37
|
+
export const AI_PROXY_BODY_LIMIT_BYTES = HOSTED_METHOD_BODY_LIMIT;
|
|
38
|
+
|
|
33
39
|
/**
|
|
34
40
|
* Request headers that must NOT be forwarded upstream: origin-sensitive
|
|
35
41
|
* browser context (`origin`/`referer`/`cookie`, since the upstream is a
|
|
@@ -280,6 +286,25 @@ export async function fetchAiUpstream(
|
|
|
280
286
|
});
|
|
281
287
|
}
|
|
282
288
|
|
|
289
|
+
/**
|
|
290
|
+
* Read an AI proxy request's body, at most {@link AI_PROXY_BODY_LIMIT_BYTES}.
|
|
291
|
+
* Past the limit it answers 413, and on a broken upload 400, and returns
|
|
292
|
+
* null: the caller sends nothing upstream. Both proxy routes read through it.
|
|
293
|
+
*/
|
|
294
|
+
export async function readAiProxyBody(
|
|
295
|
+
req: IncomingMessage,
|
|
296
|
+
res: ServerResponse,
|
|
297
|
+
): Promise<Uint8Array<ArrayBuffer> | null> {
|
|
298
|
+
try {
|
|
299
|
+
return await collectRawBody(req, AI_PROXY_BODY_LIMIT_BYTES);
|
|
300
|
+
} catch (error) {
|
|
301
|
+
const tooLarge = (error as { code?: unknown }).code === BODY_TOO_LARGE_CODE;
|
|
302
|
+
const status = tooLarge ? 413 : 400;
|
|
303
|
+
res.writeHead(status, { 'content-type': 'text/plain' }).end(tooLarge ? 'Payload Too Large' : 'Bad Request');
|
|
304
|
+
return null;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
283
308
|
/**
|
|
284
309
|
* Handle `POST /__pyric/ai-proxy/<suffix>`: a same-origin passthrough to the
|
|
285
310
|
* configured OpenAI-compatible upstream, so the browser openai engine
|
|
@@ -290,7 +315,11 @@ export async function fetchAiUpstream(
|
|
|
290
315
|
* - POST only (the OpenAI chat-completions surface is POST); 405 otherwise.
|
|
291
316
|
* - the path suffix + query ride through verbatim
|
|
292
317
|
* (`/__pyric/ai-proxy/chat/completions` becomes `<upstream>/chat/completions`).
|
|
293
|
-
* - the
|
|
318
|
+
* - the caller must pass the namespace's loopback guard (Host, and an
|
|
319
|
+
* Origin naming this server when one is sent), which the namespace runs
|
|
320
|
+
* before this handler: the upstream may carry the developer's key.
|
|
321
|
+
* - the request body is forwarded verbatim, up to
|
|
322
|
+
* {@link AI_PROXY_BODY_LIMIT_BYTES} (413 past it); headers minus the
|
|
294
323
|
* origin-sensitive/hop-by-hop set above (`authorization` DOES forward,
|
|
295
324
|
* since upstreams may require a key).
|
|
296
325
|
* - the response body is STREAMED through chunk-by-chunk. SSE passthrough
|
|
@@ -319,17 +348,9 @@ export async function handleAiProxy(
|
|
|
319
348
|
const suffix = url.pathname.slice(AI_PROXY_ROUTE.length);
|
|
320
349
|
const target = `${upstreamBase}${suffix}${url.search}`;
|
|
321
350
|
|
|
322
|
-
//
|
|
323
|
-
const
|
|
324
|
-
|
|
325
|
-
req.on('data', (c: Buffer) => chunks.push(c));
|
|
326
|
-
req.on('end', () => resolve(Buffer.concat(chunks)));
|
|
327
|
-
req.on('error', reject);
|
|
328
|
-
});
|
|
329
|
-
// Copy into a plain-ArrayBuffer view; fetch's BodyInit typing rejects
|
|
330
|
-
// Buffer's ArrayBufferLike backing.
|
|
331
|
-
const body = new Uint8Array(raw.byteLength);
|
|
332
|
-
body.set(raw);
|
|
351
|
+
// The request body is a bounded JSON payload, read once; the RESPONSE streams.
|
|
352
|
+
const body = await readAiProxyBody(req, res);
|
|
353
|
+
if (body === null) return;
|
|
333
354
|
|
|
334
355
|
const headers: Record<string, string> = {};
|
|
335
356
|
for (const [key, value] of Object.entries(req.headers)) {
|
|
@@ -355,6 +376,16 @@ export async function handleAiProxy(
|
|
|
355
376
|
return;
|
|
356
377
|
}
|
|
357
378
|
|
|
379
|
+
await relayAiUpstream(upstream, res);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Send an upstream's answer to the caller: its status and content type, then
|
|
384
|
+
* the body chunk by chunk. SSE passthrough must never buffer. A dropped
|
|
385
|
+
* client cancels the upstream read so an abandoned stream doesn't keep the
|
|
386
|
+
* upstream generating.
|
|
387
|
+
*/
|
|
388
|
+
export async function relayAiUpstream(upstream: Response, res: ServerResponse): Promise<void> {
|
|
358
389
|
const responseHeaders: Record<string, string> = { 'cache-control': 'no-store' };
|
|
359
390
|
const contentType = upstream.headers.get('content-type');
|
|
360
391
|
if (contentType) responseHeaders['content-type'] = contentType;
|
|
@@ -365,8 +396,6 @@ export async function handleAiProxy(
|
|
|
365
396
|
}
|
|
366
397
|
(res as ServerResponse & { flushHeaders?: () => void }).flushHeaders?.();
|
|
367
398
|
|
|
368
|
-
// Chunk-by-chunk passthrough. A dropped client cancels the upstream read
|
|
369
|
-
// so an abandoned SSE stream doesn't keep the upstream generating.
|
|
370
399
|
const reader = upstream.body.getReader();
|
|
371
400
|
res.on('close', () => {
|
|
372
401
|
void reader.cancel().catch(() => {});
|
|
@@ -30,6 +30,8 @@ export interface AiRejectionPayload {
|
|
|
30
30
|
code?: unknown;
|
|
31
31
|
/** The rejection reason: production's own message for shape errors. */
|
|
32
32
|
message?: unknown;
|
|
33
|
+
/** Pyric's guidance beside production's message, such as the App Check gate's. */
|
|
34
|
+
hint?: unknown;
|
|
33
35
|
}
|
|
34
36
|
|
|
35
37
|
/** Loosely-typed mirror of the AI blocked-response payload. Unlike a
|
|
@@ -99,10 +101,10 @@ function formatModelLine(model: unknown, engine: unknown): string | null {
|
|
|
99
101
|
/**
|
|
100
102
|
* Format a relayed AI broker rejection into the compact terminal block, the
|
|
101
103
|
* same ` [pyric] ...` idiom the denial relay and the ai-proxy warning print.
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
* unit tests.
|
|
104
|
+
* The reason production itself gives, then the model (with the resolved
|
|
105
|
+
* engine, since a rejection reads very differently for `scripted` than for a
|
|
106
|
+
* real upstream), then the wire status, then pyric's guidance when the broker
|
|
107
|
+
* gave one (the App Check gate does). Exported for unit tests.
|
|
106
108
|
*/
|
|
107
109
|
export function formatAiRejectionBlock(payload: AiRejectionPayload): string {
|
|
108
110
|
const message = readText(payload.message) ?? 'request rejected';
|
|
@@ -117,6 +119,8 @@ export function formatAiRejectionBlock(payload: AiRejectionPayload): string {
|
|
|
117
119
|
if (code !== null) suffix = ` (${code})`;
|
|
118
120
|
lines.push(` ${label}${suffix}`);
|
|
119
121
|
}
|
|
122
|
+
const hint = readText(payload.hint);
|
|
123
|
+
if (hint !== null) lines.push(` ${hint}`);
|
|
120
124
|
return lines.join('\n');
|
|
121
125
|
}
|
|
122
126
|
|
|
@@ -37,6 +37,17 @@ export function capTerminalText(text: string): string {
|
|
|
37
37
|
const TERMINAL_UNSAFE_CHARS =
|
|
38
38
|
/[\u0000-\u001f\u007f-\u009f\u061c\u200e\u200f\u2028-\u202e\u2066-\u2069\ufeff]/g;
|
|
39
39
|
|
|
40
|
+
/**
|
|
41
|
+
* Replace control, bidi-override and line-separator characters with spaces,
|
|
42
|
+
* and change nothing else: no flattening, no whitespace collapsing, no cut.
|
|
43
|
+
* For one line of text a reader copies out of the terminal, such as a
|
|
44
|
+
* pasteable command or JSON fixture, where the exact spacing and the whole
|
|
45
|
+
* length matter. The caller splits lines first and bounds the total.
|
|
46
|
+
*/
|
|
47
|
+
export function stripTerminalControls(text: string): string {
|
|
48
|
+
return text.replace(TERMINAL_UNSAFE_CHARS, ' ');
|
|
49
|
+
}
|
|
50
|
+
|
|
40
51
|
/**
|
|
41
52
|
* Flatten an upstream-authored string into ONE terminal-safe line.
|
|
42
53
|
*
|
package/src/serve/entries/ai.ts
CHANGED
|
@@ -16,8 +16,11 @@
|
|
|
16
16
|
* config with no `baseUrl` targets serve's same-origin `/__pyric/ai-proxy`
|
|
17
17
|
* route (which forwards to the configured upstream, default
|
|
18
18
|
* `http://localhost:11434/v1`) — so a localhost Ollama needs no CORS setup
|
|
19
|
-
* on EITHER path. A
|
|
20
|
-
*
|
|
19
|
+
* on EITHER path. A gemini config without its own `apiKey` sends through
|
|
20
|
+
* `/__pyric/ai-proxy/gemini`, where the server attaches the key from its
|
|
21
|
+
* environment, so the key never reaches the page. A custom AnswerEngine
|
|
22
|
+
* OBJECT can't cross the port and is rejected in worker mode rather than
|
|
23
|
+
* creating a second page-local backend.
|
|
21
24
|
* Direct/in-process `pyric/ai` sandboxes still accept custom engines.
|
|
22
25
|
*
|
|
23
26
|
* Engine config is per-sandbox (#98.4) and first-call-wins at the worker
|
|
@@ -27,12 +30,14 @@
|
|
|
27
30
|
*/
|
|
28
31
|
import * as ipAi from 'pyric/ai';
|
|
29
32
|
import { aiErrorFromEnvelope, unpackAiEvidence, getAiEvidence, setAiEvidence } from 'pyric/ai/internal';
|
|
33
|
+
import type { AppCheckClaim } from 'pyric/ai/internal';
|
|
30
34
|
import { getAI as pyricGetAI } from 'pyric/ai';
|
|
31
35
|
import { createTransportAI } from 'pyric/ai/internal';
|
|
32
36
|
import { FirebaseError, getApp, type FirebaseApp } from 'pyric/app';
|
|
33
37
|
import { isSandboxAppDeleted } from 'pyric/app/internal';
|
|
34
38
|
import type { AiEngineConfigWire } from '../worker/protocol.js';
|
|
35
39
|
import { aiGenerateContent, aiCountTokens, aiStreamGenerateContent } from '../worker/client.js';
|
|
40
|
+
import { AI_PROXY_PATH, browserGeminiEngine } from '../worker/ai-proxy-paths.js';
|
|
36
41
|
import { useWorker } from './worker-runtime.js';
|
|
37
42
|
import { workerClientForApp } from './app-client.js';
|
|
38
43
|
|
|
@@ -80,9 +85,6 @@ export {
|
|
|
80
85
|
type EngineOption = NonNullable<ipAi.AIOptions['engine']>;
|
|
81
86
|
type AnswerTransport = Parameters<typeof createTransportAI>[2];
|
|
82
87
|
|
|
83
|
-
/** The serve proxy route the browser openai engine defaults to (#98.2). */
|
|
84
|
-
const AI_PROXY_PATH = '/__pyric/ai-proxy';
|
|
85
|
-
|
|
86
88
|
/** A custom AnswerEngine object (vs a plain, worker-cloneable EngineConfig). */
|
|
87
89
|
function isAnswerEngine(engine: EngineOption): boolean {
|
|
88
90
|
return typeof (engine as { generateContent?: unknown }).generateContent === 'function';
|
|
@@ -150,20 +152,7 @@ function wireToEngineOption(wire: AiEngineConfigWire): EngineOption {
|
|
|
150
152
|
} as EngineOption;
|
|
151
153
|
}
|
|
152
154
|
const isGeminiWire = wire.kind === 'gemini';
|
|
153
|
-
if (isGeminiWire)
|
|
154
|
-
const result: Record<string, unknown> = {
|
|
155
|
-
kind: 'gemini',
|
|
156
|
-
};
|
|
157
|
-
const hasBaseUrl = wire.baseUrl !== undefined;
|
|
158
|
-
if (hasBaseUrl) {
|
|
159
|
-
result.baseUrl = wire.baseUrl;
|
|
160
|
-
}
|
|
161
|
-
const hasApiKey = wire.apiKey !== undefined;
|
|
162
|
-
if (hasApiKey) {
|
|
163
|
-
result.apiKey = wire.apiKey;
|
|
164
|
-
}
|
|
165
|
-
return result as unknown as EngineOption;
|
|
166
|
-
}
|
|
155
|
+
if (isGeminiWire) return browserGeminiEngine(wire) as EngineOption;
|
|
167
156
|
return {
|
|
168
157
|
kind: 'scripted',
|
|
169
158
|
...(wire.script !== undefined
|
|
@@ -172,7 +161,10 @@ function wireToEngineOption(wire: AiEngineConfigWire): EngineOption {
|
|
|
172
161
|
} as EngineOption;
|
|
173
162
|
}
|
|
174
163
|
|
|
175
|
-
/**
|
|
164
|
+
/**
|
|
165
|
+
* In-page path: default an openai config's absent baseUrl to the proxy, and
|
|
166
|
+
* send a gemini config without its own key through the gemini proxy.
|
|
167
|
+
*/
|
|
176
168
|
function withProxyDefault(options?: ipAi.AIOptions): ipAi.AIOptions | undefined {
|
|
177
169
|
const engine = options?.engine;
|
|
178
170
|
if (!engine || isAnswerEngine(engine)) return options;
|
|
@@ -180,6 +172,10 @@ function withProxyDefault(options?: ipAi.AIOptions): ipAi.AIOptions | undefined
|
|
|
180
172
|
if (config.kind === 'openai' && config.baseUrl === undefined) {
|
|
181
173
|
return { ...options, engine: { ...config, baseUrl: AI_PROXY_PATH } as EngineOption };
|
|
182
174
|
}
|
|
175
|
+
const isKeylessGemini = config.kind === 'gemini' && (engine as { apiKey?: string }).apiKey === undefined;
|
|
176
|
+
if (isKeylessGemini) {
|
|
177
|
+
return { ...options, engine: browserGeminiEngine({ kind: 'gemini' }) as EngineOption };
|
|
178
|
+
}
|
|
183
179
|
return options;
|
|
184
180
|
}
|
|
185
181
|
|
|
@@ -191,22 +187,27 @@ function aiErrorFromWire(err: unknown, modelResource: string, op: string): unkno
|
|
|
191
187
|
|
|
192
188
|
// ── The port-forwarding AnswerEngine (worker path) ─────────────────────────
|
|
193
189
|
|
|
190
|
+
/**
|
|
191
|
+
* The handle's App Check claim rides every request to the worker broker,
|
|
192
|
+
* which plays AI Logic's gate (`pyric/ai`'s `app-check-gate.ts`).
|
|
193
|
+
*/
|
|
194
194
|
function portEngine(db: ReturnType<typeof workerClientForApp>, engineWire: AiEngineConfigWire | undefined): AnswerTransport {
|
|
195
|
-
const params = (model: string, request: Record<string, unknown
|
|
195
|
+
const params = (model: string, request: Record<string, unknown>, appCheck: AppCheckClaim | undefined) => ({
|
|
196
196
|
model,
|
|
197
197
|
request,
|
|
198
198
|
...(engineWire !== undefined ? { engine: engineWire } : {}),
|
|
199
|
+
...(appCheck !== undefined ? { appCheck } : {}),
|
|
199
200
|
});
|
|
200
201
|
const engine = {
|
|
201
|
-
async generateContent(req: Record<string, unknown>, model: string): Promise<Record<string, unknown>> {
|
|
202
|
+
async generateContent(req: Record<string, unknown>, model: string, appCheck?: AppCheckClaim): Promise<Record<string, unknown>> {
|
|
202
203
|
try {
|
|
203
|
-
return unpackAiEvidence(await aiGenerateContent(db, params(model, req)));
|
|
204
|
+
return unpackAiEvidence(await aiGenerateContent(db, params(model, req, appCheck)));
|
|
204
205
|
} catch (err) {
|
|
205
206
|
throw aiErrorFromWire(err, model, 'generateContent');
|
|
206
207
|
}
|
|
207
208
|
},
|
|
208
|
-
streamGenerateContent(req: Record<string, unknown>, model: string): AsyncIterable<Record<string, unknown>> {
|
|
209
|
-
const inner = aiStreamGenerateContent(db, params(model, req));
|
|
209
|
+
streamGenerateContent(req: Record<string, unknown>, model: string, appCheck?: AppCheckClaim): AsyncIterable<Record<string, unknown>> {
|
|
210
|
+
const inner = aiStreamGenerateContent(db, params(model, req, appCheck));
|
|
210
211
|
return (async function* mapped(): AsyncGenerator<Record<string, unknown>> {
|
|
211
212
|
try {
|
|
212
213
|
for await (const chunk of inner) yield unpackAiEvidence(chunk);
|
|
@@ -215,9 +216,9 @@ function portEngine(db: ReturnType<typeof workerClientForApp>, engineWire: AiEng
|
|
|
215
216
|
}
|
|
216
217
|
})();
|
|
217
218
|
},
|
|
218
|
-
async countTokens(req: Record<string, unknown>, model: string): Promise<Record<string, unknown>> {
|
|
219
|
+
async countTokens(req: Record<string, unknown>, model: string, appCheck?: AppCheckClaim): Promise<Record<string, unknown>> {
|
|
219
220
|
try {
|
|
220
|
-
return unpackAiEvidence(await aiCountTokens(db, params(model, req)));
|
|
221
|
+
return unpackAiEvidence(await aiCountTokens(db, params(model, req, appCheck)));
|
|
221
222
|
} catch (err) {
|
|
222
223
|
throw aiErrorFromWire(err, model, 'countTokens');
|
|
223
224
|
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Masking an upstream Gemini error before the gemini proxy relays it to the
|
|
3
|
+
* browser (#1584). An upstream or a gateway can quote the credential it was
|
|
4
|
+
* sent in any form: in the URL (`?key=`, `&key=`, JSON-escaped or
|
|
5
|
+
* percent-encoded), in plain text, or in an echoed header such as
|
|
6
|
+
* `x-goog-api-key` or `Authorization: Bearer`. So the proxy removes the exact
|
|
7
|
+
* credentials the request carried, raw and percent-encoded, then runs
|
|
8
|
+
* `redactUrl` as a second pass for any other credential parameter.
|
|
9
|
+
*
|
|
10
|
+
* A JSON body is masked value by value and serialized again, so the
|
|
11
|
+
* browser engine still reads Gemini's own status and message. Text
|
|
12
|
+
* redaction over the raw JSON could eat a closing quote and leave the
|
|
13
|
+
* envelope unparseable.
|
|
14
|
+
*/
|
|
15
|
+
import { redactUrl } from 'pyric/ai/internal';
|
|
16
|
+
|
|
17
|
+
const MASK = '***';
|
|
18
|
+
|
|
19
|
+
/** The forms a credential can appear in, longest first. */
|
|
20
|
+
function quotedForms(credential: string): string[] {
|
|
21
|
+
const forms = new Set([encodeURIComponent(credential), credential]);
|
|
22
|
+
return [...forms].sort((a, b) => b.length - a.length);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function redactText(text: string, credentials: readonly string[]): string {
|
|
26
|
+
let masked = text;
|
|
27
|
+
for (const credential of credentials) {
|
|
28
|
+
for (const form of quotedForms(credential)) masked = masked.split(form).join(MASK);
|
|
29
|
+
}
|
|
30
|
+
return redactUrl(masked);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function redactValue(value: unknown, credentials: readonly string[]): unknown {
|
|
34
|
+
if (typeof value === 'string') return redactText(value, credentials);
|
|
35
|
+
if (Array.isArray(value)) return value.map((item) => redactValue(item, credentials));
|
|
36
|
+
const isObject = value !== null && typeof value === 'object';
|
|
37
|
+
if (!isObject) return value;
|
|
38
|
+
const entries = Object.entries(value as Record<string, unknown>)
|
|
39
|
+
.map(([key, item]) => [redactText(key, credentials), redactValue(item, credentials)]);
|
|
40
|
+
return Object.fromEntries(entries);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The JSON value `text` holds, or undefined when it is not JSON. */
|
|
44
|
+
function parseJson(text: string): { value: unknown } | undefined {
|
|
45
|
+
try {
|
|
46
|
+
return { value: JSON.parse(text) as unknown };
|
|
47
|
+
} catch {
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* `text` with every credential in `credentials` removed, and every
|
|
54
|
+
* credential URL parameter masked. A JSON body stays JSON.
|
|
55
|
+
*/
|
|
56
|
+
export function redactUpstreamError(text: string, credentials: readonly string[]): string {
|
|
57
|
+
const json = parseJson(text);
|
|
58
|
+
if (json === undefined) return redactText(text, credentials);
|
|
59
|
+
return JSON.stringify(redactValue(json.value, credentials));
|
|
60
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `/__pyric/ai-proxy/gemini`: where a browser gemini engine sends (#1584).
|
|
3
|
+
*
|
|
4
|
+
* A page or SharedWorker has no server environment, and the Gemini API key
|
|
5
|
+
* must never reach it. The browser engine sends each request here with no
|
|
6
|
+
* credentials. The dev server sends it on with the gemini engine's own
|
|
7
|
+
* request code (`GeminiUpstream` from `pyric/ai/internal`): the key from the
|
|
8
|
+
* server's environment or ADC, to the server's configured base URL. A
|
|
9
|
+
* successful answer streams back unchanged. An error comes back with its
|
|
10
|
+
* status and with any credential it quotes masked, so the browser engine
|
|
11
|
+
* reads Gemini's own error and never the key. The body is bounded as the
|
|
12
|
+
* openai route's is.
|
|
13
|
+
*
|
|
14
|
+
* The browser chooses only the model and the call. The target and the
|
|
15
|
+
* credentials are the server's, so no page can send the key anywhere else.
|
|
16
|
+
* The App Check gate runs before any of this, in the broker that owns the
|
|
17
|
+
* browser engine.
|
|
18
|
+
*/
|
|
19
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
20
|
+
import { GeminiUpstream, type GeminiUpstreamOptions } from 'pyric/ai/internal';
|
|
21
|
+
import { redactUpstreamError } from './gemini-error-redaction.js';
|
|
22
|
+
import type { ServeLogger } from './server.js';
|
|
23
|
+
import { fetchAiUpstream, readAiProxyBody, relayAiUpstream, type AiDiagnosticThrottle } from './ai-proxy.js';
|
|
24
|
+
import { GEMINI_PROXY_PATH } from './worker/ai-proxy-paths.js';
|
|
25
|
+
import { serializeError, type AiEngineConfigWire } from './worker/protocol.js';
|
|
26
|
+
|
|
27
|
+
export { GEMINI_PROXY_PATH } from './worker/ai-proxy-paths.js';
|
|
28
|
+
|
|
29
|
+
/** The server's gemini engine config: the Vite plugin's `ai.engine`, when it is gemini. */
|
|
30
|
+
export interface GeminiProxyConfig {
|
|
31
|
+
baseUrl?: string;
|
|
32
|
+
apiKey?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The gemini proxy's config from a configured engine, when that engine is gemini. */
|
|
36
|
+
export function geminiProxyConfig(engine: AiEngineConfigWire | undefined): GeminiProxyConfig | undefined {
|
|
37
|
+
const isGemini = engine?.kind === 'gemini';
|
|
38
|
+
if (!isGemini) return undefined;
|
|
39
|
+
return {
|
|
40
|
+
...(engine.baseUrl !== undefined ? { baseUrl: engine.baseUrl } : {}),
|
|
41
|
+
...(engine.apiKey !== undefined ? { apiKey: engine.apiKey } : {}),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** `/v1beta/models/<model>:<call>`, for the three calls the browser engine makes. */
|
|
46
|
+
const GEMINI_CALL_PATH = /^\/v1beta\/(models\/[A-Za-z0-9._-]+):(generateContent|streamGenerateContent|countTokens)$/;
|
|
47
|
+
|
|
48
|
+
/** The query each call may carry: only the stream asks for server-sent events. */
|
|
49
|
+
const CALL_QUERY: Record<string, string> = {
|
|
50
|
+
generateContent: '',
|
|
51
|
+
countTokens: '',
|
|
52
|
+
streamGenerateContent: '?alt=sse',
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/** Is this request for the gemini proxy (and not the OpenAI-compatible one)? */
|
|
56
|
+
export function isGeminiProxyPath(pathname: string): boolean {
|
|
57
|
+
return pathname.startsWith(`${GEMINI_PROXY_PATH}/`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The resource and the action `GeminiUpstream.send` takes, or null for anything else. */
|
|
61
|
+
function geminiCall(url: URL): { resource: string; action: string } | null {
|
|
62
|
+
const match = url.pathname.slice(GEMINI_PROXY_PATH.length).match(GEMINI_CALL_PATH);
|
|
63
|
+
if (match === null) return null;
|
|
64
|
+
const [, resource, call] = match as unknown as [string, string, string];
|
|
65
|
+
const isExpectedQuery = url.search === CALL_QUERY[call];
|
|
66
|
+
if (!isExpectedQuery) return null;
|
|
67
|
+
return { resource, action: `${call}${url.search}` };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Answer with a Gemini error envelope, as the upstream would. */
|
|
71
|
+
function sendEnvelope(res: ServerResponse, code: number, message: string, status: string): void {
|
|
72
|
+
res.writeHead(code, { 'content-type': 'application/json', 'cache-control': 'no-store' });
|
|
73
|
+
res.end(JSON.stringify({ error: { code, message, status } }));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Send a non-2xx upstream answer with the credentials the request carried
|
|
78
|
+
* removed, in whatever form the upstream quotes them, and a JSON envelope
|
|
79
|
+
* kept parseable (`gemini-error-redaction.ts`).
|
|
80
|
+
*/
|
|
81
|
+
async function relayRedactedError(upstream: Response, credentials: readonly string[], res: ServerResponse): Promise<void> {
|
|
82
|
+
const text = redactUpstreamError(await upstream.text().catch(() => ''), credentials);
|
|
83
|
+
const headers: Record<string, string> = { 'cache-control': 'no-store' };
|
|
84
|
+
const contentType = upstream.headers.get('content-type');
|
|
85
|
+
if (contentType) headers['content-type'] = contentType;
|
|
86
|
+
res.writeHead(upstream.status, headers);
|
|
87
|
+
res.end(text);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The gemini proxy handler. One `GeminiUpstream` serves every request, so an
|
|
92
|
+
* ADC token is looked up once and reused, as the engine itself does.
|
|
93
|
+
* `seams` replaces the ADC lookup in tests.
|
|
94
|
+
*/
|
|
95
|
+
export function createGeminiProxy(
|
|
96
|
+
config: GeminiProxyConfig | undefined,
|
|
97
|
+
throttle: AiDiagnosticThrottle,
|
|
98
|
+
logger?: ServeLogger,
|
|
99
|
+
seams: Pick<GeminiUpstreamOptions, 'printAdcToken'> = {},
|
|
100
|
+
): (req: IncomingMessage, res: ServerResponse, url: URL) => Promise<void> {
|
|
101
|
+
const upstream = new GeminiUpstream({
|
|
102
|
+
...seams,
|
|
103
|
+
...(config?.baseUrl !== undefined ? { baseUrl: config.baseUrl } : {}),
|
|
104
|
+
...(config?.apiKey !== undefined ? { apiKey: config.apiKey } : {}),
|
|
105
|
+
fetch: Object.assign(
|
|
106
|
+
(input: Parameters<typeof fetch>[0], init?: RequestInit) => fetchAiUpstream(input, init, throttle, logger),
|
|
107
|
+
{ preconnect: fetch.preconnect },
|
|
108
|
+
),
|
|
109
|
+
});
|
|
110
|
+
return async (req, res, url) => {
|
|
111
|
+
if (req.method !== 'POST') {
|
|
112
|
+
res.writeHead(405, { allow: 'POST' }).end('method not allowed');
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
const call = geminiCall(url);
|
|
116
|
+
if (call === null) {
|
|
117
|
+
sendEnvelope(res, 404, `pyric dev ai-proxy: ${url.pathname} is not a Gemini call the proxy sends.`, 'NOT_FOUND');
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
const body = await readAiProxyBody(req, res);
|
|
121
|
+
if (body === null) return;
|
|
122
|
+
let response: Response;
|
|
123
|
+
let credentials: string[];
|
|
124
|
+
try {
|
|
125
|
+
({ response, credentials } = await upstream.sendWithCredentials(call.resource, call.action, body));
|
|
126
|
+
} catch (error) {
|
|
127
|
+
// Missing credentials (401) and an unreachable upstream (502) come as
|
|
128
|
+
// the engine's own envelopes, already redacted.
|
|
129
|
+
const envelope = serializeError(error).aiEnvelope;
|
|
130
|
+
if (envelope === undefined) throw error;
|
|
131
|
+
sendEnvelope(res, envelope.error.code, envelope.error.message, envelope.error.status);
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
if (response.ok) {
|
|
135
|
+
await relayAiUpstream(response, res);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
await relayRedactedError(response, credentials, res);
|
|
139
|
+
};
|
|
140
|
+
}
|