@pyric/cli 0.1.0-alpha.30 → 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 +8 -0
- package/dist/assurance/.generated/conformance-verdicts.d.ts.map +1 -1
- package/dist/assurance/.generated/conformance-verdicts.js +8 -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/cli/serve.d.ts +2 -0
- package/dist/cli/serve.d.ts.map +1 -1
- package/dist/cli/serve.js +13 -2
- package/dist/cli/serve.js.map +1 -1
- package/dist/conformance/.generated/can-i-use-browser.js +2 -2
- package/dist/conformance/.generated/can-i-use-browser.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/register/app-bridge-admin-app-check.d.ts +3 -0
- package/dist/register/app-bridge-admin-app-check.d.ts.map +1 -0
- package/dist/register/app-bridge-admin-app-check.js +43 -0
- package/dist/register/app-bridge-admin-app-check.js.map +1 -0
- package/dist/register/app-bridge-app-check.d.ts +4 -0
- package/dist/register/app-bridge-app-check.d.ts.map +1 -1
- package/dist/register/app-bridge-app-check.js +4 -0
- package/dist/register/app-bridge-app-check.js.map +1 -1
- package/dist/register/app-bridge-shadow.d.ts +4 -2
- package/dist/register/app-bridge-shadow.d.ts.map +1 -1
- package/dist/register/app-bridge-shadow.js +29 -5
- package/dist/register/app-bridge-shadow.js.map +1 -1
- package/dist/register/index.d.ts.map +1 -1
- package/dist/register/index.js +21 -0
- package/dist/register/index.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/bundler.d.ts +40 -0
- package/dist/serve/bundler.d.ts.map +1 -1
- package/dist/serve/bundler.js +133 -2
- package/dist/serve/bundler.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/entries/app-ai-passthrough.d.ts +4 -4
- package/dist/serve/entries/app-ai-passthrough.d.ts.map +1 -1
- package/dist/serve/entries/app-ai-passthrough.js +8 -52
- package/dist/serve/entries/app-ai-passthrough.js.map +1 -1
- package/dist/serve/firebase-module-swap.d.ts +22 -0
- package/dist/serve/firebase-module-swap.d.ts.map +1 -1
- package/dist/serve/firebase-module-swap.js +51 -3
- package/dist/serve/firebase-module-swap.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/hosted/method-protocol.d.ts +4 -4
- package/dist/serve/html-injection.d.ts +2 -0
- package/dist/serve/html-injection.d.ts.map +1 -1
- package/dist/serve/html-injection.js +4 -2
- package/dist/serve/html-injection.js.map +1 -1
- package/dist/serve/namespace.d.ts +9 -0
- package/dist/serve/namespace.d.ts.map +1 -1
- package/dist/serve/namespace.js +71 -15
- 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 +29 -10
- package/dist/serve/site-ui/docs/ai-compat.md +30 -9
- package/dist/serve/site-ui/docs/app-check-compat/index.html +42 -18
- package/dist/serve/site-ui/docs/app-check-compat.md +42 -18
- package/dist/serve/site-ui/docs/auth-compat/index.html +4 -4
- package/dist/serve/site-ui/docs/auth-compat.md +4 -4
- package/dist/serve/site-ui/docs/build/ai-logic/index.html +21 -5
- package/dist/serve/site-ui/docs/build/ai-logic.md +23 -2
- package/dist/serve/site-ui/docs/database-compat/index.html +4 -4
- package/dist/serve/site-ui/docs/database-compat.md +4 -4
- package/dist/serve/site-ui/docs/firestore-compat/index.html +4 -4
- package/dist/serve/site-ui/docs/firestore-compat.md +4 -4
- package/dist/serve/site-ui/docs/index.json +10 -0
- package/dist/serve/site-ui/docs/messaging-compat/index.html +4 -4
- package/dist/serve/site-ui/docs/messaging-compat.md +4 -4
- package/dist/serve/site-ui/docs/pyric-admin-app-reference-api/index.html +4 -0
- package/dist/serve/site-ui/docs/pyric-admin-app-reference-api.md +5 -0
- package/dist/serve/site-ui/docs/pyric-cli-register-app-bridge-app-check-reference-api/index.html +2 -0
- package/dist/serve/site-ui/docs/pyric-cli-register-app-bridge-app-check-reference-api.md +3 -0
- package/dist/serve/site-ui/docs/storage-compat/index.html +5 -5
- package/dist/serve/site-ui/docs/storage-compat.md +5 -5
- 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 +8 -0
- package/src/bridge/server/peer.ts +49 -1
- package/src/cli/serve.ts +14 -2
- package/src/conformance/.generated/can-i-use-browser.ts +2 -2
- package/src/conformance/.generated/can-i-use.ts +1 -1
- package/src/conformance/.generated/conformance-docs.ts +1 -1
- package/src/register/app-bridge-admin-app-check.ts +46 -0
- package/src/register/app-bridge-app-check.ts +4 -0
- package/src/register/app-bridge-shadow.ts +35 -5
- package/src/register/index.ts +23 -0
- 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/bundler.ts +163 -2
- package/src/serve/entries/ai.ts +28 -27
- package/src/serve/entries/app-ai-passthrough.ts +8 -58
- package/src/serve/firebase-module-swap.ts +52 -3
- package/src/serve/gemini-error-redaction.ts +60 -0
- package/src/serve/gemini-proxy.ts +140 -0
- package/src/serve/html-injection.ts +4 -2
- package/src/serve/namespace.ts +74 -15
- 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
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
|
}
|
|
@@ -2,68 +2,18 @@
|
|
|
2
2
|
import { bindAppRegistrySandbox } from 'pyric/app/internal';
|
|
3
3
|
import { sandbox } from './app-backend.js';
|
|
4
4
|
import * as pyricApp from 'pyric/app';
|
|
5
|
-
import
|
|
5
|
+
import { shadowedAppRegistry } from '../../register/app-bridge-shadow.js';
|
|
6
6
|
|
|
7
7
|
bindAppRegistrySandbox(sandbox);
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
rawSettings?: string | pyricApp.FirebaseAppSettings,
|
|
13
|
-
): pyricApp.FirebaseApp {
|
|
14
|
-
try {
|
|
15
|
-
let shadow: { container?: unknown };
|
|
16
|
-
try {
|
|
17
|
-
shadow = realApp.getApp(app.name) as { container?: unknown };
|
|
18
|
-
} catch {
|
|
19
|
-
const initOptions = (options ?? app.options) as realApp.FirebaseOptions;
|
|
20
|
-
const initSettings = rawSettings ?? app.name;
|
|
21
|
-
const isStringSettings = typeof initSettings === 'string';
|
|
22
|
-
if (isStringSettings) {
|
|
23
|
-
shadow = realApp.initializeApp(initOptions, initSettings) as { container?: unknown };
|
|
24
|
-
} else {
|
|
25
|
-
const objectSettings = initSettings as realApp.FirebaseAppSettings;
|
|
26
|
-
shadow = realApp.initializeApp(initOptions, objectSettings) as { container?: unknown };
|
|
27
|
-
}
|
|
28
|
-
}
|
|
29
|
-
const hasShadowContainer = shadow.container !== undefined && shadow.container !== null;
|
|
30
|
-
if (hasShadowContainer) {
|
|
31
|
-
Object.defineProperty(app, 'container', { value: shadow.container, configurable: true });
|
|
32
|
-
}
|
|
33
|
-
} catch {
|
|
34
|
-
// Observational/best-effort: do not break Pyric initialization if shadow initialization fails.
|
|
35
|
-
}
|
|
36
|
-
return app;
|
|
37
|
-
}
|
|
9
|
+
// One shadow implementation for every plane: the register adapters build
|
|
10
|
+
// their exports from the same registry.
|
|
11
|
+
const registry = shadowedAppRegistry(pyricApp);
|
|
38
12
|
|
|
39
|
-
export
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
const app = pyricApp.initializeApp(options, rawSettings);
|
|
44
|
-
return decorateApp(app, options, rawSettings);
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
export function getApp(name?: string): pyricApp.FirebaseApp {
|
|
48
|
-
return decorateApp(pyricApp.getApp(name));
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
export function getApps(): pyricApp.FirebaseApp[] {
|
|
52
|
-
return pyricApp.getApps().map((app) => decorateApp(app));
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
export async function deleteApp(app: pyricApp.FirebaseApp): Promise<void> {
|
|
56
|
-
try {
|
|
57
|
-
const shadow = realApp.getApp(app.name);
|
|
58
|
-
const hasShadow = shadow !== undefined && shadow !== null;
|
|
59
|
-
if (hasShadow) {
|
|
60
|
-
await realApp.deleteApp(shadow);
|
|
61
|
-
}
|
|
62
|
-
} catch {
|
|
63
|
-
// Ignore if shadow app was not initialized or already deleted.
|
|
64
|
-
}
|
|
65
|
-
return pyricApp.deleteApp(app);
|
|
66
|
-
}
|
|
13
|
+
export const initializeApp = registry.initializeApp;
|
|
14
|
+
export const getApp = registry.getApp;
|
|
15
|
+
export const getApps = registry.getApps;
|
|
16
|
+
export const deleteApp = registry.deleteApp;
|
|
67
17
|
|
|
68
18
|
export {
|
|
69
19
|
FirebaseError,
|
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
* container.
|
|
9
9
|
*
|
|
10
10
|
* Every plane that swaps Firebase modules reads {@link swappedFirebaseEntries}:
|
|
11
|
-
* the `pyric sandbox` import map (`html-injection.ts
|
|
11
|
+
* the `pyric sandbox` import map (`html-injection.ts`, through
|
|
12
|
+
* {@link importMapFirebaseEntries}) and the Vite resolver
|
|
12
13
|
* (`vite-module-swap.ts`). Every front door resolves the mode with
|
|
13
14
|
* {@link resolveAiMode}. One table and one precedence rule, so the planes
|
|
14
15
|
* cannot disagree about what production mode means. It imports only Node
|
|
@@ -78,6 +79,34 @@ export function swappedFirebaseEntries(mode: AiMode): ReadonlyMap<ServedFirebase
|
|
|
78
79
|
return swapped;
|
|
79
80
|
}
|
|
80
81
|
|
|
82
|
+
/**
|
|
83
|
+
* The Firebase SDK modules `pyric sandbox` builds for the browser from the
|
|
84
|
+
* project's own `firebase` install in production AI mode, to the entry key of
|
|
85
|
+
* each (`/__pyric/sdk/<key>.js`). One build, so the three share one
|
|
86
|
+
* `@firebase/app` component registry. The Vite and register planes resolve
|
|
87
|
+
* the Firebase SDK themselves and never read this.
|
|
88
|
+
*/
|
|
89
|
+
export const REAL_SDK_ENTRIES: ReadonlyMap<ServedFirebaseSpecifier, string> = new Map([
|
|
90
|
+
['firebase/app', 'firebase-sdk-app'],
|
|
91
|
+
['firebase/ai', 'firebase-sdk-ai'],
|
|
92
|
+
['firebase/app-check', 'firebase-sdk-app-check'],
|
|
93
|
+
]);
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The import-map plane's specifier to entry key table: the swap, plus, in
|
|
97
|
+
* production AI mode, the unswapped `firebase/ai` sent to the Firebase SDK
|
|
98
|
+
* that `pyric sandbox` builds. A browser has no package resolution, so on
|
|
99
|
+
* this plane an unswapped specifier still needs a URL.
|
|
100
|
+
*/
|
|
101
|
+
export function importMapFirebaseEntries(mode: AiMode): ReadonlyMap<ServedFirebaseSpecifier, string> {
|
|
102
|
+
const entries = new Map(swappedFirebaseEntries(mode));
|
|
103
|
+
for (const [specifier, key] of REAL_SDK_ENTRIES) {
|
|
104
|
+
const isUnswapped = !entries.has(specifier);
|
|
105
|
+
if (isUnswapped) entries.set(specifier, key);
|
|
106
|
+
}
|
|
107
|
+
return entries;
|
|
108
|
+
}
|
|
109
|
+
|
|
81
110
|
/** This package's root (`src/serve/` and `dist/serve/` both sit two levels below it), as written and resolved. */
|
|
82
111
|
const PACKAGE_ROOTS: readonly string[] = (() => {
|
|
83
112
|
const root = fileURLToPath(new URL('../../', import.meta.url)).replaceAll('\\', '/');
|
|
@@ -110,10 +139,30 @@ export function isShadowAppImporter(importer: string | undefined): boolean {
|
|
|
110
139
|
return PACKAGE_ROOTS.some((root) => file.startsWith(root) && SHADOW_MODULE.test(file.slice(root.length)));
|
|
111
140
|
}
|
|
112
141
|
|
|
113
|
-
/**
|
|
114
|
-
|
|
142
|
+
/**
|
|
143
|
+
* The SDK modules a shadow-app wrapper imports for itself. The firebase-admin
|
|
144
|
+
* pair is the register's App Check verifier for production AI mode
|
|
145
|
+
* (`register/app-bridge-admin-app-check.ts`); no browser plane serves them.
|
|
146
|
+
*/
|
|
147
|
+
const BRIDGED_SPECIFIERS: ReadonlySet<string> = new Set([
|
|
148
|
+
'firebase/app',
|
|
149
|
+
'firebase/app-check',
|
|
150
|
+
'firebase-admin/app',
|
|
151
|
+
'firebase-admin/app-check',
|
|
152
|
+
]);
|
|
115
153
|
|
|
116
154
|
/** Whether this import is a shadow-app wrapper reaching the Firebase SDK it wraps. */
|
|
117
155
|
export function isBypassedBridgeImport(specifier: string, importer: string | undefined): boolean {
|
|
118
156
|
return BRIDGED_SPECIFIERS.has(specifier) && isShadowAppImporter(importer);
|
|
119
157
|
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Where a bypassed bridge import goes on the import-map plane: the entry key
|
|
161
|
+
* of the Firebase SDK module `pyric sandbox` builds. The bare specifier
|
|
162
|
+
* cannot stay, because the import map sends it back to the wrapper itself.
|
|
163
|
+
* Undefined for every import {@link isBypassedBridgeImport} does not name.
|
|
164
|
+
*/
|
|
165
|
+
export function bridgeImportEntryKey(specifier: string, importer: string | undefined): string | undefined {
|
|
166
|
+
if (!isBypassedBridgeImport(specifier, importer)) return undefined;
|
|
167
|
+
return REAL_SDK_ENTRIES.get(specifier as ServedFirebaseSpecifier);
|
|
168
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -2,15 +2,17 @@ import { stampHostedTarget } from './runtime/hosted-target.js';
|
|
|
2
2
|
import type { OwnersConfig } from './owners-config.js';
|
|
3
3
|
import { REACT_BOOTSTRAP } from './react-bootstrap.js';
|
|
4
4
|
import { SANDBOX_BUILD_MARKER } from './sandbox-marker.js';
|
|
5
|
-
import {
|
|
5
|
+
import { importMapFirebaseEntries, type AiMode } from './firebase-module-swap.js';
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
8
|
* The import-map targets, specifier to served URL, for one AI mode. Derived
|
|
9
9
|
* from the shared swap table, so the Vite resolver serves the same modules.
|
|
10
|
+
* In production AI mode `firebase/ai` goes to the Firebase SDK that
|
|
11
|
+
* `pyric sandbox` builds from the project's `firebase` install.
|
|
10
12
|
*/
|
|
11
13
|
export function sdkImportMap(mode: AiMode = 'sandbox'): Record<string, string> {
|
|
12
14
|
const imports: Record<string, string> = {};
|
|
13
|
-
for (const [specifier, entryKey] of
|
|
15
|
+
for (const [specifier, entryKey] of importMapFirebaseEntries(mode)) {
|
|
14
16
|
imports[specifier] = `/__pyric/sdk/${entryKey}.js`;
|
|
15
17
|
}
|
|
16
18
|
return imports;
|