omnigateway 0.1.1 → 0.1.3

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/gateway.js CHANGED
@@ -5866,6 +5866,7 @@ async function* decodeAnthropic(messages) {
5866
5866
  let cacheWriteTokens = 0;
5867
5867
  let outputTokens = 0;
5868
5868
  let stopReason = "endTurn";
5869
+ let terminal = false;
5869
5870
  for await (const msg of messages) {
5870
5871
  const d = json(msg.data);
5871
5872
  if (d === null)
@@ -5931,6 +5932,7 @@ async function* decodeAnthropic(messages) {
5931
5932
  break;
5932
5933
  }
5933
5934
  case "message_stop":
5935
+ terminal = true;
5934
5936
  yield {
5935
5937
  type: "end",
5936
5938
  stopReason,
@@ -5938,6 +5940,7 @@ async function* decodeAnthropic(messages) {
5938
5940
  };
5939
5941
  break;
5940
5942
  case "error": {
5943
+ terminal = true;
5941
5944
  const code = ERROR_TYPE[String(d.error?.type)] ?? "UPSTREAM";
5942
5945
  yield {
5943
5946
  type: "error",
@@ -5951,6 +5954,14 @@ async function* decodeAnthropic(messages) {
5951
5954
  break;
5952
5955
  }
5953
5956
  }
5957
+ if (!terminal) {
5958
+ yield {
5959
+ type: "error",
5960
+ code: "UPSTREAM",
5961
+ message: "upstream stream ended before message_stop",
5962
+ retryable: RETRYABLE.UPSTREAM
5963
+ };
5964
+ }
5954
5965
  }
5955
5966
 
5956
5967
  // packages/providers/src/anthropic/wire.ts
@@ -6063,9 +6074,10 @@ var anthropicAdapter = {
6063
6074
  ["anthropic-version", API_VERSION],
6064
6075
  ["Accept", req.request.stream ? "text/event-stream" : "application/json"]
6065
6076
  ];
6077
+ const betas = new Set(req.request.betas ?? []);
6066
6078
  if (oauth) {
6067
6079
  protocol.push(["Authorization", `Bearer ${req.credentials.accessToken}`]);
6068
- protocol.push(["anthropic-beta", OAUTH_BETA]);
6080
+ betas.add(OAUTH_BETA);
6069
6081
  } else if (req.credentials.apiKey !== null) {
6070
6082
  protocol.push(["x-api-key", req.credentials.apiKey]);
6071
6083
  } else {
@@ -6073,6 +6085,8 @@ var anthropicAdapter = {
6073
6085
  provider: "anthropic"
6074
6086
  });
6075
6087
  }
6088
+ if (betas.size > 0)
6089
+ protocol.push(["anthropic-beta", [...betas].join(",")]);
6076
6090
  const profile = PROFILES.anthropic;
6077
6091
  const headers = orderHeaders(mergeHeaders(profile.headers, protocol), profile.order);
6078
6092
  const bodyString = signAnthropicBody(JSON.stringify(orderFields(withSystem, BODY_ORDER.anthropic)));
@@ -6290,18 +6304,16 @@ async function* decodeChat(messages) {
6290
6304
  let started = false;
6291
6305
  let textOpen = false;
6292
6306
  let textIndex;
6293
- let ended = false;
6307
+ let done = false;
6294
6308
  let stopReason = "endTurn";
6295
6309
  let usage = { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 };
6296
6310
  const toolIndex = new Map;
6297
6311
  let nextIndex = 0;
6298
- const emitEnd = () => {
6299
- ended = true;
6300
- return { type: "end", stopReason, usage };
6301
- };
6302
6312
  for await (const msg of messages) {
6303
- if (msg.data === "[DONE]")
6313
+ if (msg.data === "[DONE]") {
6314
+ done = true;
6304
6315
  break;
6316
+ }
6305
6317
  const d = json2(msg.data);
6306
6318
  if (d === null)
6307
6319
  continue;
@@ -6356,19 +6368,21 @@ async function* decodeChat(messages) {
6356
6368
  }
6357
6369
  if (typeof choice.finish_reason === "string") {
6358
6370
  stopReason = FINISH[choice.finish_reason] ?? "endTurn";
6359
- if (textOpen)
6360
- yield { type: "blockEnd", index: textIndex ?? 0 };
6361
- for (const index of toolIndex.values())
6362
- yield { type: "blockEnd", index };
6363
- yield emitEnd();
6364
6371
  }
6365
6372
  }
6366
- if (!ended) {
6373
+ if (done) {
6367
6374
  if (textOpen)
6368
6375
  yield { type: "blockEnd", index: textIndex ?? 0 };
6369
6376
  for (const index of toolIndex.values())
6370
6377
  yield { type: "blockEnd", index };
6371
- yield emitEnd();
6378
+ yield { type: "end", stopReason, usage };
6379
+ } else {
6380
+ yield {
6381
+ type: "error",
6382
+ code: "UPSTREAM",
6383
+ message: "upstream stream ended before [DONE]",
6384
+ retryable: RETRYABLE.UPSTREAM
6385
+ };
6372
6386
  }
6373
6387
  }
6374
6388
 
@@ -6523,6 +6537,7 @@ async function* decodeResponses(messages) {
6523
6537
  return assigned;
6524
6538
  };
6525
6539
  let sawToolCall = false;
6540
+ let terminal = false;
6526
6541
  const ownsBlock = new Set;
6527
6542
  for await (const msg of messages) {
6528
6543
  const d = json3(msg.data);
@@ -6598,6 +6613,7 @@ async function* decodeResponses(messages) {
6598
6613
  }
6599
6614
  case "response.completed":
6600
6615
  case "response.incomplete": {
6616
+ terminal = true;
6601
6617
  const r = d.response ?? {};
6602
6618
  const reason = r.incomplete_details?.reason;
6603
6619
  let stopReason = sawToolCall ? "toolUse" : "endTurn";
@@ -6619,6 +6635,7 @@ async function* decodeResponses(messages) {
6619
6635
  }
6620
6636
  case "response.failed":
6621
6637
  case "error": {
6638
+ terminal = true;
6622
6639
  const err = d.response?.error ?? d.error ?? {};
6623
6640
  const code = ERROR_CODE[String(err.code ?? err.type)] ?? "UPSTREAM";
6624
6641
  yield {
@@ -6633,6 +6650,14 @@ async function* decodeResponses(messages) {
6633
6650
  break;
6634
6651
  }
6635
6652
  }
6653
+ if (!terminal) {
6654
+ yield {
6655
+ type: "error",
6656
+ code: "UPSTREAM",
6657
+ message: "upstream stream ended before response completion",
6658
+ retryable: RETRYABLE.UPSTREAM
6659
+ };
6660
+ }
6636
6661
  }
6637
6662
 
6638
6663
  // packages/providers/src/openai/wire.ts
@@ -21679,25 +21704,27 @@ function requireDimension(grain, dimension) {
21679
21704
  }
21680
21705
 
21681
21706
  // packages/control/src/credentials.ts
21707
+ function summarizeCredential(credential) {
21708
+ return {
21709
+ id: credential.id,
21710
+ provider: credential.provider,
21711
+ label: credential.label,
21712
+ authType: credential.authType,
21713
+ enabled: credential.enabled,
21714
+ tier: credential.tier,
21715
+ weight: credential.weight,
21716
+ expiresAt: credential.expiresAt,
21717
+ accountEmail: credential.accountEmail,
21718
+ providerData: credential.providerData,
21719
+ disabledReason: credential.disabledReason,
21720
+ disabledAt: credential.disabledAt,
21721
+ hasRefreshToken: credential.hasRefreshToken,
21722
+ createdAt: credential.createdAt,
21723
+ updatedAt: credential.updatedAt
21724
+ };
21725
+ }
21682
21726
  async function listCredentials(store) {
21683
- const credentials = await store.credentials.list();
21684
- return credentials.map((c) => ({
21685
- id: c.id,
21686
- provider: c.provider,
21687
- label: c.label,
21688
- authType: c.authType,
21689
- enabled: c.enabled,
21690
- tier: c.tier,
21691
- weight: c.weight,
21692
- expiresAt: c.expiresAt,
21693
- accountEmail: c.accountEmail,
21694
- providerData: c.providerData,
21695
- disabledReason: c.disabledReason,
21696
- disabledAt: c.disabledAt,
21697
- hasRefreshToken: c.hasRefreshToken,
21698
- createdAt: c.createdAt,
21699
- updatedAt: c.updatedAt
21700
- }));
21727
+ return (await store.credentials.list()).map(summarizeCredential);
21701
21728
  }
21702
21729
  async function credentialHealth(store) {
21703
21730
  const [health, quota] = await Promise.all([
@@ -21732,7 +21759,7 @@ function healthKey(credentialId, model) {
21732
21759
  }
21733
21760
  async function buildSnapshot(store, now) {
21734
21761
  const [credentials, healthRows, quotaRows, models, settings] = await Promise.all([
21735
- store.credentials.list(),
21762
+ store.credentials.listRouting(),
21736
21763
  store.credentials.listHealth(),
21737
21764
  store.credentials.listQuota(),
21738
21765
  store.config.listModels(),
@@ -22151,7 +22178,7 @@ var DEFAULT_SETTINGS = {
22151
22178
  // packages/store/src/sqlite/config.ts
22152
22179
  var SETTINGS_KEY = "settings";
22153
22180
  var ADMIN_HASH_KEY = "adminPasswordHash";
22154
- function createConfigRepo(db) {
22181
+ function createConfigRepo(db, emit = () => {}) {
22155
22182
  const readRaw = (key) => db.query("SELECT value FROM settings WHERE key = ?").get(key)?.value ?? null;
22156
22183
  const writeRaw = (key, value) => {
22157
22184
  db.run("INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT (key) DO UPDATE SET value = excluded.value", [key, value]);
@@ -22171,9 +22198,11 @@ function createConfigRepo(db) {
22171
22198
  targets = excluded.targets,
22172
22199
  strategy = excluded.strategy,
22173
22200
  is_alias = excluded.is_alias`, [model.id, JSON.stringify(model.targets), model.strategy, model.isAlias ? 1 : 0]);
22201
+ emit({ type: "modelsChanged" });
22174
22202
  },
22175
22203
  async removeModel(id) {
22176
22204
  db.run("DELETE FROM virtual_models WHERE id = ?", [id]);
22205
+ emit({ type: "modelsChanged" });
22177
22206
  },
22178
22207
  async getSettings() {
22179
22208
  const raw = readRaw(SETTINGS_KEY);
@@ -22194,6 +22223,7 @@ function createConfigRepo(db) {
22194
22223
  weights: { ...current.weights, ...patch.weights }
22195
22224
  };
22196
22225
  writeRaw(SETTINGS_KEY, JSON.stringify(next));
22226
+ emit({ type: "settingsChanged" });
22197
22227
  return next;
22198
22228
  },
22199
22229
  async getAdminPasswordHash() {
@@ -22209,8 +22239,40 @@ function createConfigRepo(db) {
22209
22239
  };
22210
22240
  }
22211
22241
  // packages/store/src/sqlite/credentials.ts
22212
- function createCredentialRepo(db, key) {
22213
- const view = (row) => ({
22242
+ function createCredentialRepo(db, key, emit = () => {}) {
22243
+ const open = async (v) => v === null ? null : decrypt(key, v);
22244
+ const secretsFrom = async (row) => ({
22245
+ accessToken: await open(row.access_token),
22246
+ refreshToken: await open(row.refresh_token),
22247
+ apiKey: await open(row.api_key),
22248
+ idToken: await open(row.id_token)
22249
+ });
22250
+ const requiredRow = (row, id) => {
22251
+ if (row === null)
22252
+ throw new Error(`credential ${id} no longer exists`);
22253
+ return row;
22254
+ };
22255
+ const currentSecrets = async (id) => {
22256
+ const row = requiredRow(db.query("SELECT access_token, refresh_token, api_key, id_token FROM credentials WHERE id = ?").get(id), id);
22257
+ return secretsFrom(row);
22258
+ };
22259
+ const currentInferenceSecrets = async (id, authType) => {
22260
+ if (authType === "oauth") {
22261
+ const row2 = requiredRow(db.query("SELECT access_token FROM credentials WHERE id = ?").get(id), id);
22262
+ return { accessToken: await open(row2.access_token), apiKey: null };
22263
+ }
22264
+ const row = requiredRow(db.query("SELECT api_key FROM credentials WHERE id = ?").get(id), id);
22265
+ return { accessToken: null, apiKey: await open(row.api_key) };
22266
+ };
22267
+ const currentRefreshSecrets = async (id) => {
22268
+ const row = requiredRow(db.query("SELECT refresh_token FROM credentials WHERE id = ?").get(id), id);
22269
+ return { refreshToken: await open(row.refresh_token) };
22270
+ };
22271
+ const currentUsageSecrets = async (id) => {
22272
+ const row = requiredRow(db.query("SELECT access_token FROM credentials WHERE id = ?").get(id), id);
22273
+ return { accessToken: await open(row.access_token) };
22274
+ };
22275
+ const view = (row, loadCurrentSecrets = false) => ({
22214
22276
  id: row.id,
22215
22277
  provider: row.provider,
22216
22278
  label: row.label,
@@ -22226,18 +22288,31 @@ function createCredentialRepo(db, key) {
22226
22288
  hasRefreshToken: row.refresh_token !== null,
22227
22289
  createdAt: row.created_at,
22228
22290
  updatedAt: row.updated_at,
22229
- secrets: async () => ({
22230
- accessToken: await open(row.access_token),
22231
- refreshToken: await open(row.refresh_token),
22232
- apiKey: await open(row.api_key),
22233
- idToken: await open(row.id_token)
22234
- })
22291
+ secrets: () => loadCurrentSecrets ? currentSecrets(row.id) : secretsFrom(row),
22292
+ openForInference: async () => {
22293
+ if (loadCurrentSecrets)
22294
+ return currentInferenceSecrets(row.id, row.auth_type);
22295
+ if (row.auth_type === "oauth") {
22296
+ return { accessToken: await open(row.access_token), apiKey: null };
22297
+ }
22298
+ return { accessToken: null, apiKey: await open(row.api_key) };
22299
+ },
22300
+ openForRefresh: async () => loadCurrentSecrets ? currentRefreshSecrets(row.id) : { refreshToken: await open(row.refresh_token) },
22301
+ openForUsage: async () => loadCurrentSecrets ? currentUsageSecrets(row.id) : { accessToken: await open(row.access_token) }
22235
22302
  });
22236
- const open = async (v) => v === null ? null : decrypt(key, v);
22237
22303
  const seal = async (v) => v === null || v === undefined ? null : encrypt(key, v);
22238
22304
  return {
22239
22305
  async list() {
22240
- return db.query("SELECT * FROM credentials ORDER BY tier, label").all().map(view);
22306
+ return db.query("SELECT * FROM credentials ORDER BY tier, label").all().map((row) => view(row));
22307
+ },
22308
+ async listRouting() {
22309
+ return db.query(`SELECT id, provider, label, auth_type, enabled, tier, weight, expires_at,
22310
+ account_email, provider_data, disabled_reason, disabled_at,
22311
+ NULL AS access_token,
22312
+ CASE WHEN refresh_token IS NULL THEN NULL ELSE 'present' END AS refresh_token,
22313
+ NULL AS api_key, NULL AS id_token, created_at, updated_at
22314
+ FROM credentials
22315
+ ORDER BY tier, label`).all().map((row) => view(row, true));
22241
22316
  },
22242
22317
  async get(id) {
22243
22318
  const row = db.query("SELECT * FROM credentials WHERE id = ?").get(id);
@@ -22270,6 +22345,7 @@ function createCredentialRepo(db, key) {
22270
22345
  now
22271
22346
  ]);
22272
22347
  const { accessToken, refreshToken, apiKey, idToken, ...meta3 } = input;
22348
+ emit({ type: "credentialsChanged" });
22273
22349
  return {
22274
22350
  ...meta3,
22275
22351
  hasRefreshToken: refreshToken != null,
@@ -22306,6 +22382,7 @@ function createCredentialRepo(db, key) {
22306
22382
  return;
22307
22383
  put("updated_at", Date.now());
22308
22384
  db.run(`UPDATE credentials SET ${sets.join(", ")} WHERE id = ?`, [...vals, id]);
22385
+ emit({ type: "credentialsChanged" });
22309
22386
  },
22310
22387
  async updateSecrets(id, secrets, expiresAt) {
22311
22388
  const sets = [];
@@ -22329,9 +22406,11 @@ function createCredentialRepo(db, key) {
22329
22406
  sets.push("expires_at = ?", "updated_at = ?");
22330
22407
  vals.push(expiresAt, Date.now());
22331
22408
  db.run(`UPDATE credentials SET ${sets.join(", ")} WHERE id = ?`, [...vals, id]);
22409
+ emit({ type: "credentialsChanged" });
22332
22410
  },
22333
22411
  async remove(id) {
22334
22412
  db.run("DELETE FROM credentials WHERE id = ?", [id]);
22413
+ emit({ type: "credentialsChanged" });
22335
22414
  },
22336
22415
  async listHealth() {
22337
22416
  return db.query("SELECT * FROM credential_health").all().map((r) => ({
@@ -22362,6 +22441,7 @@ function createCredentialRepo(db, key) {
22362
22441
  stmt.run(r.credentialId, r.model, r.breakerState, r.consecutiveFailures, r.openedAt, r.rateLimitedUntil, r.ewmaTtftMs, r.lastUsedAt);
22363
22442
  }
22364
22443
  })();
22444
+ emit({ type: "healthSaved", rows });
22365
22445
  },
22366
22446
  async listQuota() {
22367
22447
  return db.query("SELECT * FROM quota_windows").all().map((r) => ({
@@ -22401,6 +22481,7 @@ function createCredentialRepo(db, key) {
22401
22481
  prune.run(credentialId, JSON.stringify(types2));
22402
22482
  }
22403
22483
  })();
22484
+ emit({ type: "quotaSaved", rows });
22404
22485
  }
22405
22486
  };
22406
22487
  }
@@ -22873,11 +22954,26 @@ function createUsageRepo(db) {
22873
22954
  // packages/store/src/sqlite/store.ts
22874
22955
  async function createStore(opts) {
22875
22956
  const db = openDb(opts.path);
22957
+ const listeners = new Set;
22958
+ const emit = (change) => {
22959
+ for (const listener of listeners) {
22960
+ try {
22961
+ listener(change);
22962
+ } catch {}
22963
+ }
22964
+ };
22876
22965
  return {
22877
- credentials: createCredentialRepo(db, opts.encryptionKey),
22878
- config: createConfigRepo(db),
22966
+ credentials: createCredentialRepo(db, opts.encryptionKey, emit),
22967
+ config: createConfigRepo(db, emit),
22879
22968
  keys: createKeyRepo(db),
22880
22969
  usage: createUsageRepo(db),
22970
+ routing: {
22971
+ version: () => db.query("PRAGMA data_version").get()?.data_version ?? 0,
22972
+ subscribe(listener) {
22973
+ listeners.add(listener);
22974
+ return () => listeners.delete(listener);
22975
+ }
22976
+ },
22881
22977
  close: () => db.close()
22882
22978
  };
22883
22979
  }
@@ -23202,7 +23298,7 @@ var SCHEDULER_REFRESH_LEAD_MS = 300000;
23202
23298
  function createRefresher(deps) {
23203
23299
  const inFlight = new Map;
23204
23300
  async function run(credential) {
23205
- const secrets = await credential.secrets();
23301
+ const secrets = await credential.openForRefresh();
23206
23302
  if (secrets.refreshToken === null) {
23207
23303
  throw new GatewayError("AUTH", `credential ${credential.id} has no refresh token`);
23208
23304
  }
@@ -23248,10 +23344,8 @@ async function probe(deps, credential) {
23248
23344
  const provider = deps.providers[credential.provider];
23249
23345
  if (provider.usage === undefined)
23250
23346
  return null;
23251
- let secrets = await credential.secrets();
23252
- if (credential.hasRefreshToken && credential.expiresAt !== null && credential.expiresAt - SCHEDULER_REFRESH_LEAD_MS <= deps.now()) {
23253
- secrets = await deps.refresh(credential);
23254
- }
23347
+ const refreshed = credential.hasRefreshToken && credential.expiresAt !== null && credential.expiresAt - SCHEDULER_REFRESH_LEAD_MS <= deps.now() ? await deps.refresh(credential) : null;
23348
+ const secrets = refreshed === null ? await credential.openForUsage() : { accessToken: refreshed.accessToken };
23255
23349
  const report = await provider.usage(secrets, { http: deps.http, now: deps.now }, credential.providerData);
23256
23350
  if (report === null || report.windows.length === 0)
23257
23351
  return null;
@@ -37744,6 +37838,64 @@ class ApiKeyRateLimiter {
37744
37838
  }
37745
37839
  }
37746
37840
 
37841
+ // apps/gateway/src/dispatch/snapshotCache.ts
37842
+ function createRoutingSnapshotCache(store) {
37843
+ let snapshot = null;
37844
+ let stale = true;
37845
+ let version2 = store.routing.version();
37846
+ let generation = 0;
37847
+ let inFlight = null;
37848
+ const apply = (change) => {
37849
+ generation++;
37850
+ if (snapshot === null || stale)
37851
+ return;
37852
+ if (change.type === "healthSaved") {
37853
+ const health = new Map(snapshot.health);
37854
+ for (const row of change.rows)
37855
+ health.set(healthKey(row.credentialId, row.model), row);
37856
+ snapshot = { ...snapshot, health };
37857
+ return;
37858
+ }
37859
+ if (change.type === "quotaSaved") {
37860
+ const quota = new Map(snapshot.quota);
37861
+ const credentialIds = new Set(change.rows.map((row) => row.credentialId));
37862
+ for (const credentialId of credentialIds) {
37863
+ quota.set(credentialId, change.rows.filter((row) => row.credentialId === credentialId));
37864
+ }
37865
+ snapshot = { ...snapshot, quota };
37866
+ return;
37867
+ }
37868
+ stale = true;
37869
+ };
37870
+ const unsubscribe = store.routing.subscribe(apply);
37871
+ const get = async (now) => {
37872
+ const currentVersion = store.routing.version();
37873
+ if (currentVersion !== version2)
37874
+ stale = true;
37875
+ if (snapshot !== null && !stale)
37876
+ return snapshot;
37877
+ if (inFlight !== null)
37878
+ return inFlight;
37879
+ const buildGeneration = generation;
37880
+ const buildVersion = currentVersion;
37881
+ inFlight = buildSnapshot(store, now).then(async (next) => {
37882
+ const completedVersion = store.routing.version();
37883
+ if (generation !== buildGeneration || completedVersion !== buildVersion) {
37884
+ inFlight = null;
37885
+ return get(now);
37886
+ }
37887
+ snapshot = next;
37888
+ version2 = completedVersion;
37889
+ stale = false;
37890
+ return next;
37891
+ }).finally(() => {
37892
+ inFlight = null;
37893
+ });
37894
+ return inFlight;
37895
+ };
37896
+ return { get, close: unsubscribe };
37897
+ }
37898
+
37747
37899
  // apps/gateway/src/ingress/schemas.ts
37748
37900
  function parseOrThrow2(schema, body2) {
37749
37901
  const result = schema.safeParse(body2);
@@ -38021,7 +38173,7 @@ function extractToken(header) {
38021
38173
  async function attempt(opts) {
38022
38174
  const { candidate, adapter, http, now, signal, refresh, refreshLeadMs } = opts;
38023
38175
  const credential = candidate.credential;
38024
- let secrets = opts.secrets ?? await credential.secrets();
38176
+ let secrets = opts.secrets ?? await credential.openForInference();
38025
38177
  const stale = opts.secrets === undefined && credential.authType === "oauth" && credential.expiresAt !== null && credential.expiresAt - refreshLeadMs <= now;
38026
38178
  if (stale) {
38027
38179
  if (!credential.hasRefreshToken) {
@@ -38072,7 +38224,7 @@ function classify(error51) {
38072
38224
  // apps/gateway/src/dispatch/index.ts
38073
38225
  async function dispatch(request2, deps, signal) {
38074
38226
  const startedAt = deps.now();
38075
- const snapshot = await buildSnapshot(deps.store, startedAt);
38227
+ const snapshot = await deps.snapshots.get(startedAt);
38076
38228
  const deadlineAt = startedAt + snapshot.settings.requestDeadlineMs;
38077
38229
  const log = {
38078
38230
  id: crypto.randomUUID(),
@@ -38192,12 +38344,18 @@ async function dispatch(request2, deps, signal) {
38192
38344
  }), dispatchSignal);
38193
38345
  for (const d of result.degradations)
38194
38346
  log.degradations.push(d);
38347
+ let terminal = false;
38348
+ const pending = [];
38195
38349
  for await (const event of result.events) {
38196
38350
  if (event.type === "blockDelta" && !committed) {
38197
38351
  committed = true;
38198
38352
  log.ttftMs = deps.now() - startedAt;
38353
+ for (const buffered of pending)
38354
+ yield buffered;
38355
+ pending.length = 0;
38199
38356
  }
38200
38357
  if (event.type === "end") {
38358
+ terminal = true;
38201
38359
  log.inputTokens = event.usage.inputTokens;
38202
38360
  log.outputTokens = event.usage.outputTokens;
38203
38361
  log.cacheReadTokens = event.usage.cacheReadTokens;
@@ -38205,6 +38363,7 @@ async function dispatch(request2, deps, signal) {
38205
38363
  log.costUsd = priceOf(candidate, event.usage);
38206
38364
  }
38207
38365
  if (event.type === "error") {
38366
+ terminal = true;
38208
38367
  if (!committed && RETRYABLE[event.code]) {
38209
38368
  throw new GatewayError(event.code, event.message);
38210
38369
  }
@@ -38221,7 +38380,21 @@ async function dispatch(request2, deps, signal) {
38221
38380
  log.durationMs = deps.now() - startedAt;
38222
38381
  return;
38223
38382
  }
38224
- yield event;
38383
+ if (!committed && event.type !== "end") {
38384
+ pending.push(event);
38385
+ } else {
38386
+ if (!committed) {
38387
+ for (const buffered of pending)
38388
+ yield buffered;
38389
+ pending.length = 0;
38390
+ }
38391
+ yield event;
38392
+ }
38393
+ if (event.type === "end")
38394
+ break;
38395
+ }
38396
+ if (!terminal) {
38397
+ throw new GatewayError("UPSTREAM", "upstream stream ended without a terminal event");
38225
38398
  }
38226
38399
  await persistHealth(recordSuccess(healthFor(candidate), {
38227
38400
  settings: snapshot.settings,
@@ -38475,6 +38648,7 @@ var chunk = (id, created, model, choice, usage) => ({
38475
38648
  async function* openaiStream(events, requestId, created) {
38476
38649
  let model = "";
38477
38650
  let roleSent = false;
38651
+ let completed = false;
38478
38652
  const toolIndex = new Map;
38479
38653
  const emit = (data) => ({ event: "message", data: JSON.stringify(data) });
38480
38654
  for await (const event of events) {
@@ -38536,6 +38710,7 @@ async function* openaiStream(events, requestId, created) {
38536
38710
  case "blockEnd":
38537
38711
  break;
38538
38712
  case "end":
38713
+ completed = true;
38539
38714
  yield emit(chunk(requestId, created, model, { index: 0, delta: {}, finish_reason: FINISH2[event.stopReason] }, {
38540
38715
  prompt_tokens: event.usage.inputTokens,
38541
38716
  completion_tokens: event.usage.outputTokens,
@@ -38549,7 +38724,8 @@ async function* openaiStream(events, requestId, created) {
38549
38724
  }
38550
38725
  }
38551
38726
  }
38552
- yield { event: "message", data: "[DONE]" };
38727
+ if (completed)
38728
+ yield { event: "message", data: "[DONE]" };
38553
38729
  }
38554
38730
  function openaiResponse(collected, requestId, created) {
38555
38731
  const text = collected.content.flatMap((b) => b.type === "text" ? [b.text] : []).join("");
@@ -38728,7 +38904,14 @@ function toIrReasoning(thinking, effort) {
38728
38904
  return { mode: "off" };
38729
38905
  }
38730
38906
  }
38731
- function parseAnthropicRequest(body2) {
38907
+ function readBetas(headers) {
38908
+ const raw = headers?.get("anthropic-beta");
38909
+ if (raw === undefined || raw === null)
38910
+ return [];
38911
+ const names = raw.split(",").map((name) => name.trim()).filter((name) => name.length > 0);
38912
+ return [...new Set(names)];
38913
+ }
38914
+ function parseAnthropicRequest(body2, headers) {
38732
38915
  if (typeof body2 !== "object" || body2 === null) {
38733
38916
  throw new GatewayError("BAD_REQUEST", "request body must be a JSON object");
38734
38917
  }
@@ -38769,6 +38952,9 @@ function parseAnthropicRequest(body2) {
38769
38952
  const extras = extraFields(body2, KNOWN);
38770
38953
  if (extras !== undefined)
38771
38954
  request2.vendor = { anthropic: extras };
38955
+ const betas = readBetas(headers);
38956
+ if (betas.length > 0)
38957
+ request2.betas = betas;
38772
38958
  return validateRequest(request2);
38773
38959
  }
38774
38960
 
@@ -39002,7 +39188,7 @@ async function handle(deps, rateLimiter, surface, request2) {
39002
39188
  keyId = key.id;
39003
39189
  rateLimiter.consume(key.id, key.rateLimitPerMin);
39004
39190
  const body2 = await request2.json();
39005
- const chatRequest = surface === "anthropic" ? parseAnthropicRequest(body2) : parseOpenAIRequest(body2);
39191
+ const chatRequest = surface === "anthropic" ? parseAnthropicRequest(body2, request2.headers) : parseOpenAIRequest(body2);
39006
39192
  if (key.modelAllowlist !== null && !key.modelAllowlist.includes(chatRequest.model)) {
39007
39193
  throw new GatewayError("AUTH", `model "${chatRequest.model}" is not allowed for this API key`);
39008
39194
  }
@@ -39052,7 +39238,11 @@ async function handle(deps, rateLimiter, surface, request2) {
39052
39238
  }
39053
39239
  function proxyRoutes(deps) {
39054
39240
  const rateLimiter = deps.rateLimiter ?? new ApiKeyRateLimiter(deps.now);
39055
- return new Elysia().post("/v1/messages", ({ request: request2 }) => handle(deps, rateLimiter, "anthropic", request2)).post("/v1/chat/completions", ({ request: request2 }) => handle(deps, rateLimiter, "openai", request2)).get("/v1/models", async ({ request: request2 }) => {
39241
+ const dispatchDeps = {
39242
+ ...deps,
39243
+ snapshots: deps.snapshots ?? createRoutingSnapshotCache(deps.store)
39244
+ };
39245
+ return new Elysia().post("/v1/messages", ({ request: request2 }) => handle(dispatchDeps, rateLimiter, "anthropic", request2)).post("/v1/chat/completions", ({ request: request2 }) => handle(dispatchDeps, rateLimiter, "openai", request2)).get("/v1/models", async ({ request: request2 }) => {
39056
39246
  try {
39057
39247
  const key = await authenticateApiKey(deps.store, apiKeyHeader(request2.headers));
39058
39248
  const models = await deps.store.config.listModels();
@@ -39085,6 +39275,7 @@ function createApp(deps) {
39085
39275
  const adapters = deps.adapters ?? ADAPTERS;
39086
39276
  const requestId = deps.requestId ?? (() => `req_${crypto.randomUUID()}`);
39087
39277
  const rateLimiter = new ApiKeyRateLimiter(now);
39278
+ const snapshots = createRoutingSnapshotCache(deps.store);
39088
39279
  const admin = createAdminAuth(deps.store, { now, sessionTtlMs: ADMIN_SESSION_TTL_MS });
39089
39280
  const refresh = deps.refresh ?? createRefresher({
39090
39281
  store: deps.store,
@@ -39101,6 +39292,7 @@ function createApp(deps) {
39101
39292
  }
39102
39293
  return new Elysia().get("/health", () => ({ ok: true })).use(proxyRoutes({
39103
39294
  store: deps.store,
39295
+ snapshots,
39104
39296
  adapters,
39105
39297
  http,
39106
39298
  now,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "omnigateway",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "Self-hosted AI gateway with Anthropic- and OpenAI-compatible APIs, an admin console, and a CLI",
5
5
  "license": "MIT",
6
6
  "author": "Harismawan <mail@harismawan.com>",
@@ -1,4 +1,4 @@
1
- import{B as e,Dt as t,I as n,Kt as r,M as i,Ot as a}from"./queries-vY5lJqBe.js";import{m as o}from"./Rack-Be09hoVn.js";var s=o(`check`,[[`path`,{d:`M20 6 9 17l-5-5`,key:`1gmf2c`}]]),c=o(`copy`,[[`rect`,{width:`14`,height:`14`,x:`8`,y:`8`,rx:`2`,ry:`2`,key:`17jyea`}],[`path`,{d:`M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2`,key:`zix9uf`}]]),l=r(a(),1);function u(e){if(e===null||typeof getSelection!=`function`)return!1;let t=getSelection();if(t===null)return!1;try{let n=document.createRange();return n.selectNodeContents(e),t.removeAllRanges(),t.addRange(n),!0}catch{return!1}}async function d(e,t){if(typeof navigator<`u`&&navigator.clipboard!==void 0)try{return await navigator.clipboard.writeText(e),`copied`}catch{}let n=u(t??null);if(n&&typeof document.execCommand==`function`)try{if(document.execCommand(`copy`))return`copied`}catch{}return n?`selected`:`failed`}var f=t(),p=e.div`
1
+ import{B as e,Dt as t,I as n,Kt as r,M as i,Ot as a}from"./queries-vY5lJqBe.js";import{m as o}from"./Rack-uDxrjvaz.js";var s=o(`check`,[[`path`,{d:`M20 6 9 17l-5-5`,key:`1gmf2c`}]]),c=o(`copy`,[[`rect`,{width:`14`,height:`14`,x:`8`,y:`8`,rx:`2`,ry:`2`,key:`17jyea`}],[`path`,{d:`M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2`,key:`zix9uf`}]]),l=r(a(),1);function u(e){if(e===null||typeof getSelection!=`function`)return!1;let t=getSelection();if(t===null)return!1;try{let n=document.createRange();return n.selectNodeContents(e),t.removeAllRanges(),t.addRange(n),!0}catch{return!1}}async function d(e,t){if(typeof navigator<`u`&&navigator.clipboard!==void 0)try{return await navigator.clipboard.writeText(e),`copied`}catch{}let n=u(t??null);if(n&&typeof document.execCommand==`function`)try{if(document.execCommand(`copy`))return`copied`}catch{}return n?`selected`:`failed`}var f=t(),p=e.div`
2
2
  display: flex;
3
3
  align-items: stretch;
4
4
  gap: 0;