@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.
Files changed (169) hide show
  1. package/dist/assurance/.generated/conformance-verdicts.d.ts +8 -0
  2. package/dist/assurance/.generated/conformance-verdicts.d.ts.map +1 -1
  3. package/dist/assurance/.generated/conformance-verdicts.js +8 -0
  4. package/dist/assurance/.generated/conformance-verdicts.js.map +1 -1
  5. package/dist/bridge/server/peer.d.ts +10 -0
  6. package/dist/bridge/server/peer.d.ts.map +1 -1
  7. package/dist/bridge/server/peer.js +46 -1
  8. package/dist/bridge/server/peer.js.map +1 -1
  9. package/dist/cli/serve.d.ts +2 -0
  10. package/dist/cli/serve.d.ts.map +1 -1
  11. package/dist/cli/serve.js +13 -2
  12. package/dist/cli/serve.js.map +1 -1
  13. package/dist/conformance/.generated/can-i-use-browser.js +2 -2
  14. package/dist/conformance/.generated/can-i-use-browser.js.map +1 -1
  15. package/dist/conformance/.generated/can-i-use.d.ts.map +1 -1
  16. package/dist/conformance/.generated/can-i-use.js +1 -1
  17. package/dist/conformance/.generated/can-i-use.js.map +1 -1
  18. package/dist/conformance/.generated/conformance-docs.d.ts.map +1 -1
  19. package/dist/conformance/.generated/conformance-docs.js +1 -1
  20. package/dist/conformance/.generated/conformance-docs.js.map +1 -1
  21. package/dist/register/app-bridge-admin-app-check.d.ts +3 -0
  22. package/dist/register/app-bridge-admin-app-check.d.ts.map +1 -0
  23. package/dist/register/app-bridge-admin-app-check.js +43 -0
  24. package/dist/register/app-bridge-admin-app-check.js.map +1 -0
  25. package/dist/register/app-bridge-app-check.d.ts +4 -0
  26. package/dist/register/app-bridge-app-check.d.ts.map +1 -1
  27. package/dist/register/app-bridge-app-check.js +4 -0
  28. package/dist/register/app-bridge-app-check.js.map +1 -1
  29. package/dist/register/app-bridge-shadow.d.ts +4 -2
  30. package/dist/register/app-bridge-shadow.d.ts.map +1 -1
  31. package/dist/register/app-bridge-shadow.js +29 -5
  32. package/dist/register/app-bridge-shadow.js.map +1 -1
  33. package/dist/register/index.d.ts.map +1 -1
  34. package/dist/register/index.js +21 -0
  35. package/dist/register/index.js.map +1 -1
  36. package/dist/serve/ai-diagnostics-relay.d.ts +4 -1
  37. package/dist/serve/ai-diagnostics-relay.d.ts.map +1 -1
  38. package/dist/serve/ai-diagnostics-relay.js +3 -0
  39. package/dist/serve/ai-diagnostics-relay.js.map +1 -1
  40. package/dist/serve/ai-proxy.d.ts +21 -1
  41. package/dist/serve/ai-proxy.d.ts.map +1 -1
  42. package/dist/serve/ai-proxy.js +39 -14
  43. package/dist/serve/ai-proxy.js.map +1 -1
  44. package/dist/serve/ai-terminal-blocks.d.ts +6 -4
  45. package/dist/serve/ai-terminal-blocks.d.ts.map +1 -1
  46. package/dist/serve/ai-terminal-blocks.js +7 -4
  47. package/dist/serve/ai-terminal-blocks.js.map +1 -1
  48. package/dist/serve/ai-terminal-text.d.ts +8 -0
  49. package/dist/serve/ai-terminal-text.d.ts.map +1 -1
  50. package/dist/serve/ai-terminal-text.js +10 -0
  51. package/dist/serve/ai-terminal-text.js.map +1 -1
  52. package/dist/serve/bundler.d.ts +40 -0
  53. package/dist/serve/bundler.d.ts.map +1 -1
  54. package/dist/serve/bundler.js +133 -2
  55. package/dist/serve/bundler.js.map +1 -1
  56. package/dist/serve/entries/ai.d.ts.map +1 -1
  57. package/dist/serve/entries/ai.js +28 -26
  58. package/dist/serve/entries/ai.js.map +1 -1
  59. package/dist/serve/entries/app-ai-passthrough.d.ts +4 -4
  60. package/dist/serve/entries/app-ai-passthrough.d.ts.map +1 -1
  61. package/dist/serve/entries/app-ai-passthrough.js +8 -52
  62. package/dist/serve/entries/app-ai-passthrough.js.map +1 -1
  63. package/dist/serve/firebase-module-swap.d.ts +22 -0
  64. package/dist/serve/firebase-module-swap.d.ts.map +1 -1
  65. package/dist/serve/firebase-module-swap.js +51 -3
  66. package/dist/serve/firebase-module-swap.js.map +1 -1
  67. package/dist/serve/gemini-error-redaction.d.ts +6 -0
  68. package/dist/serve/gemini-error-redaction.d.ts.map +1 -0
  69. package/dist/serve/gemini-error-redaction.js +61 -0
  70. package/dist/serve/gemini-error-redaction.js.map +1 -0
  71. package/dist/serve/gemini-proxy.d.ts +40 -0
  72. package/dist/serve/gemini-proxy.d.ts.map +1 -0
  73. package/dist/serve/gemini-proxy.js +105 -0
  74. package/dist/serve/gemini-proxy.js.map +1 -0
  75. package/dist/serve/hosted/method-protocol.d.ts +4 -4
  76. package/dist/serve/html-injection.d.ts +2 -0
  77. package/dist/serve/html-injection.d.ts.map +1 -1
  78. package/dist/serve/html-injection.js +4 -2
  79. package/dist/serve/html-injection.js.map +1 -1
  80. package/dist/serve/namespace.d.ts +9 -0
  81. package/dist/serve/namespace.d.ts.map +1 -1
  82. package/dist/serve/namespace.js +71 -15
  83. package/dist/serve/namespace.js.map +1 -1
  84. package/dist/serve/sandbox-session.d.ts +3 -0
  85. package/dist/serve/sandbox-session.d.ts.map +1 -1
  86. package/dist/serve/sandbox-session.js +1 -0
  87. package/dist/serve/sandbox-session.js.map +1 -1
  88. package/dist/serve/site-ui/docs/ai-compat/index.html +29 -10
  89. package/dist/serve/site-ui/docs/ai-compat.md +30 -9
  90. package/dist/serve/site-ui/docs/app-check-compat/index.html +42 -18
  91. package/dist/serve/site-ui/docs/app-check-compat.md +42 -18
  92. package/dist/serve/site-ui/docs/auth-compat/index.html +4 -4
  93. package/dist/serve/site-ui/docs/auth-compat.md +4 -4
  94. package/dist/serve/site-ui/docs/build/ai-logic/index.html +21 -5
  95. package/dist/serve/site-ui/docs/build/ai-logic.md +23 -2
  96. package/dist/serve/site-ui/docs/database-compat/index.html +4 -4
  97. package/dist/serve/site-ui/docs/database-compat.md +4 -4
  98. package/dist/serve/site-ui/docs/firestore-compat/index.html +4 -4
  99. package/dist/serve/site-ui/docs/firestore-compat.md +4 -4
  100. package/dist/serve/site-ui/docs/index.json +10 -0
  101. package/dist/serve/site-ui/docs/messaging-compat/index.html +4 -4
  102. package/dist/serve/site-ui/docs/messaging-compat.md +4 -4
  103. package/dist/serve/site-ui/docs/pyric-admin-app-reference-api/index.html +4 -0
  104. package/dist/serve/site-ui/docs/pyric-admin-app-reference-api.md +5 -0
  105. package/dist/serve/site-ui/docs/pyric-cli-register-app-bridge-app-check-reference-api/index.html +2 -0
  106. package/dist/serve/site-ui/docs/pyric-cli-register-app-bridge-app-check-reference-api.md +3 -0
  107. package/dist/serve/site-ui/docs/storage-compat/index.html +5 -5
  108. package/dist/serve/site-ui/docs/storage-compat.md +5 -5
  109. package/dist/serve/site-ui/docs/tutorial/index.html +11 -1
  110. package/dist/serve/site-ui/docs/tutorial.md +12 -1
  111. package/dist/serve/site-ui/examples/chess/index.html +1 -1
  112. package/dist/serve/vite-sandbox-generation.d.ts.map +1 -1
  113. package/dist/serve/vite-sandbox-generation.js +2 -0
  114. package/dist/serve/vite-sandbox-generation.js.map +1 -1
  115. package/dist/serve/worker/ai-proxy-paths.d.ts +32 -0
  116. package/dist/serve/worker/ai-proxy-paths.d.ts.map +1 -0
  117. package/dist/serve/worker/ai-proxy-paths.js +20 -0
  118. package/dist/serve/worker/ai-proxy-paths.js.map +1 -0
  119. package/dist/serve/worker/client/ai.d.ts +3 -1
  120. package/dist/serve/worker/client/ai.d.ts.map +1 -1
  121. package/dist/serve/worker/client/ai.js +3 -0
  122. package/dist/serve/worker/client/ai.js.map +1 -1
  123. package/dist/serve/worker/entry.js +2 -0
  124. package/dist/serve/worker/entry.js.map +1 -1
  125. package/dist/serve/worker/host-ai.d.ts +4 -5
  126. package/dist/serve/worker/host-ai.d.ts.map +1 -1
  127. package/dist/serve/worker/host-ai.js +19 -23
  128. package/dist/serve/worker/host-ai.js.map +1 -1
  129. package/dist/serve/worker/host-context.d.ts +13 -4
  130. package/dist/serve/worker/host-context.d.ts.map +1 -1
  131. package/dist/serve/worker/host-context.js.map +1 -1
  132. package/dist/serve/worker/inbound-validation/operation-arguments.d.ts.map +1 -1
  133. package/dist/serve/worker/inbound-validation/operation-arguments.js +11 -0
  134. package/dist/serve/worker/inbound-validation/operation-arguments.js.map +1 -1
  135. package/dist/serve/worker/protocol.d.ts +13 -0
  136. package/dist/serve/worker/protocol.d.ts.map +1 -1
  137. package/dist/serve/worker/protocol.js.map +1 -1
  138. package/package.json +4 -4
  139. package/src/assurance/.generated/conformance-verdicts.ts +8 -0
  140. package/src/bridge/server/peer.ts +49 -1
  141. package/src/cli/serve.ts +14 -2
  142. package/src/conformance/.generated/can-i-use-browser.ts +2 -2
  143. package/src/conformance/.generated/can-i-use.ts +1 -1
  144. package/src/conformance/.generated/conformance-docs.ts +1 -1
  145. package/src/register/app-bridge-admin-app-check.ts +46 -0
  146. package/src/register/app-bridge-app-check.ts +4 -0
  147. package/src/register/app-bridge-shadow.ts +35 -5
  148. package/src/register/index.ts +23 -0
  149. package/src/serve/ai-diagnostics-relay.ts +6 -1
  150. package/src/serve/ai-proxy.ts +43 -14
  151. package/src/serve/ai-terminal-blocks.ts +8 -4
  152. package/src/serve/ai-terminal-text.ts +11 -0
  153. package/src/serve/bundler.ts +163 -2
  154. package/src/serve/entries/ai.ts +28 -27
  155. package/src/serve/entries/app-ai-passthrough.ts +8 -58
  156. package/src/serve/firebase-module-swap.ts +52 -3
  157. package/src/serve/gemini-error-redaction.ts +60 -0
  158. package/src/serve/gemini-proxy.ts +140 -0
  159. package/src/serve/html-injection.ts +4 -2
  160. package/src/serve/namespace.ts +74 -15
  161. package/src/serve/sandbox-session.ts +4 -0
  162. package/src/serve/vite-sandbox-generation.ts +2 -0
  163. package/src/serve/worker/ai-proxy-paths.ts +35 -0
  164. package/src/serve/worker/client/ai.ts +6 -1
  165. package/src/serve/worker/entry.ts +2 -0
  166. package/src/serve/worker/host-ai.ts +23 -23
  167. package/src/serve/worker/host-context.ts +13 -3
  168. package/src/serve/worker/inbound-validation/operation-arguments.ts +11 -0
  169. package/src/serve/worker/protocol.ts +14 -2
@@ -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 custom AnswerEngine OBJECT can't cross the port and is
20
- * rejected in worker mode rather than creating a second page-local backend.
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
- /** In-page path: default an openai config's absent baseUrl to the proxy. */
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 * as realApp from 'firebase/app';
5
+ import { shadowedAppRegistry } from '../../register/app-bridge-shadow.js';
6
6
 
7
7
  bindAppRegistrySandbox(sandbox);
8
8
 
9
- function decorateApp(
10
- app: pyricApp.FirebaseApp,
11
- options?: pyricApp.FirebaseOptions,
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 function initializeApp(
40
- options?: pyricApp.FirebaseOptions,
41
- rawSettings?: string | pyricApp.FirebaseAppSettings,
42
- ): pyricApp.FirebaseApp {
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`) and the Vite resolver
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
- /** The SDK modules a shadow-app wrapper imports for itself. */
114
- const BRIDGED_SPECIFIERS: ReadonlySet<string> = new Set(['firebase/app', 'firebase/app-check']);
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 { swappedFirebaseEntries, type AiMode } from './firebase-module-swap.js';
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 swappedFirebaseEntries(mode)) {
15
+ for (const [specifier, entryKey] of importMapFirebaseEntries(mode)) {
14
16
  imports[specifier] = `/__pyric/sdk/${entryKey}.js`;
15
17
  }
16
18
  return imports;