mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-12 10:12:11 +03:00
Compare commits
4 Commits
feat/9571-
...
fix/releas
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a8dfccf1c8 | ||
|
|
1dc2e4fffd | ||
|
|
297d404327 | ||
|
|
4e6f808b43 |
@@ -1,7 +1,3 @@
|
||||
feat(images): execute full combo strategy + fallback in /v1/images/generations (#9239)
|
||||
- feat(images): execute full combo strategy + fallback in /v1/images/generations (#9239)
|
||||
|
||||
Add open-sse/services/imageCombo.ts that expands combo targets, filters
|
||||
to images-capable, executes priority strategy with handleImageGeneration
|
||||
per target, and returns first success or last failure. Route patches
|
||||
detect combo names before model resolution and divert to the new
|
||||
execution path.
|
||||
Adds open-sse/services/imageCombo.ts that expands combo targets, filters to images-capable, executes the priority strategy with handleImageGeneration per target, and returns the first success or last failure. Route patches detect combo names before model resolution and divert to the new execution path.
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
---
|
||||
feature: 9490
|
||||
---
|
||||
- feat(opencode-plugin): warm catalog startup from disk snapshot + parallel refresh (#9490)
|
||||
|
||||
**Warm catalog startup from disk snapshot + parallel refresh** (opencode-plugin): The config-shim hook now reads the last disk snapshot *before* fetching, so the provider registers immediately with the last-known-good catalog (~1-2s vs ~30s on a warm gateway). All six fetchers run concurrently via `Promise.allSettled` instead of sequentially. A failed refresh keeps the snapshot (no overwrite). An in-flight guard prevents concurrent refreshes for the same cache key. The `features.diskCache: false` opt-out disables the warm read entirely.
|
||||
The config-shim hook now reads the last disk snapshot before fetching, so the provider registers immediately with the last-known-good catalog (~1-2s vs ~30s on a warm gateway). All six fetchers run concurrently via Promise.allSettled instead of sequentially. A failed refresh keeps the snapshot (no overwrite). An in-flight guard prevents concurrent refreshes for the same cache key. The features.diskCache: false opt-out disables the warm read entirely.
|
||||
|
||||
11
changelog.d/features/9571-plugin-streaming-usage-timing.md
Normal file
11
changelog.d/features/9571-plugin-streaming-usage-timing.md
Normal file
@@ -0,0 +1,11 @@
|
||||
- **feat(plugins):** add onStreamComplete built-in event exposing streaming usage and timing (#9571)
|
||||
|
||||
Adds a new `onStreamComplete` plugin event that fires after an SSE stream is fully
|
||||
consumed, carrying usage token counts and timing metrics (latency, TTFT). Built-in
|
||||
events now include `onStreamComplete` as a fire-and-forget lifecycle hook.
|
||||
|
||||
Payload: `status`, `usage` (prompt_tokens, completion_tokens, reasoning_tokens,
|
||||
cache_read_input_tokens, cache_creation_input_tokens), `timing` (latencyMs, ttft),
|
||||
`model`, `provider`, `errorCode`.
|
||||
|
||||
Non-breaking — existing `onResponse` hooks with `{ streamed: true }` remain unchanged.
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { RegistryEntry } from "../../shared.ts";
|
||||
import { CONOL_FALLBACK_MODELS } from "../../../services/conolModels.ts";
|
||||
import { CONOL_FALLBACK_MODELS } from "../../../../services/conolModels.ts";
|
||||
|
||||
export const conol_webProvider: RegistryEntry = {
|
||||
id: "conol-web",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { RegistryEntry } from "../shared";
|
||||
import type { RegistryEntry } from "../../shared";
|
||||
|
||||
export const deepaiProvider: RegistryEntry = {
|
||||
id: "deepai",
|
||||
@@ -7,6 +7,7 @@ export const deepaiProvider: RegistryEntry = {
|
||||
baseUrl: "https://api.deepai.org",
|
||||
authType: "apikey",
|
||||
authHeader: "api-key",
|
||||
executor: "default",
|
||||
models: [
|
||||
{ id: "text2img", name: "Text to Image" },
|
||||
],
|
||||
|
||||
@@ -251,7 +251,10 @@ import { recordCompressionCacheStats } from "./chatCore/compressionCacheStats.ts
|
||||
import { writeCavemanOutputAnalytics } from "./chatCore/cavemanOutputAnalytics.ts";
|
||||
import { scheduleQuotaShareConsumption } from "./chatCore/quotaShareConsumption.ts";
|
||||
import { emitRequestGamificationEvent } from "./chatCore/gamificationEvent.ts";
|
||||
import { runPluginOnResponseHook } from "./chatCore/pluginOnResponse.ts";
|
||||
import {
|
||||
runPluginOnResponseHook,
|
||||
runPluginOnStreamCompleteHook,
|
||||
} from "./chatCore/pluginOnResponse.ts";
|
||||
import { scheduleStreamingQuotaShareConsumption } from "./chatCore/streamingQuotaShare.ts";
|
||||
import { recordStreamingUsageStats } from "./chatCore/streamingUsageStats.ts";
|
||||
import { recordStreamingCost } from "./chatCore/streamingCost.ts";
|
||||
@@ -4934,6 +4937,17 @@ export async function handleChatCore({
|
||||
streamUsage,
|
||||
log,
|
||||
});
|
||||
|
||||
// Plugin onStreamComplete hook — fire-and-forget, fail-open (#9571)
|
||||
runPluginOnStreamCompleteHook({
|
||||
status: normalizedStreamStatus,
|
||||
usage: streamUsage as Record<string, unknown> | undefined,
|
||||
ttft,
|
||||
model,
|
||||
provider,
|
||||
errorCode: streamErrorCode,
|
||||
startTime,
|
||||
});
|
||||
};
|
||||
|
||||
const streamFailureFinalizers = streamFailure.createStreamFailureFinalizers({
|
||||
|
||||
@@ -45,3 +45,57 @@ export async function runPluginOnResponseHook(args: {
|
||||
/* plugin onResponse optional */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Payload passed to plugin onStreamComplete hooks after a streaming response is consumed.
|
||||
* Carries usage token counts, timing metrics (latency, TTFT), model, provider, and error code.
|
||||
*/
|
||||
export type PluginOnStreamCompletePayload = {
|
||||
status: number;
|
||||
usage?: {
|
||||
prompt_tokens?: number;
|
||||
completion_tokens?: number;
|
||||
reasoning_tokens?: number;
|
||||
cache_read_input_tokens?: number;
|
||||
cache_creation_input_tokens?: number;
|
||||
};
|
||||
timing?: {
|
||||
latencyMs: number;
|
||||
ttft?: number;
|
||||
};
|
||||
model?: string;
|
||||
provider?: string;
|
||||
errorCode?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Run plugin onStreamComplete hooks — fire-and-forget and fail-open.
|
||||
* Called inside the onStreamComplete callback (chatCore.ts) where usage and timing data
|
||||
* converge after an SSE stream is fully consumed.
|
||||
*/
|
||||
export async function runPluginOnStreamCompleteHook(args: {
|
||||
status: number;
|
||||
usage?: Record<string, unknown>;
|
||||
ttft?: number;
|
||||
model: string | null | undefined;
|
||||
provider: string | null | undefined;
|
||||
errorCode?: string | null | undefined;
|
||||
startTime: number;
|
||||
}): Promise<void> {
|
||||
try {
|
||||
const { runOnStreamComplete } = await import("@/lib/plugins/hooks");
|
||||
runOnStreamComplete({
|
||||
status: args.status,
|
||||
usage: args.usage as PluginOnStreamCompletePayload["usage"],
|
||||
timing: {
|
||||
latencyMs: Date.now() - args.startTime,
|
||||
ttft: args.ttft,
|
||||
},
|
||||
model: args.model ?? undefined,
|
||||
provider: args.provider ?? undefined,
|
||||
errorCode: args.errorCode ?? undefined,
|
||||
}).catch(() => {});
|
||||
} catch (_) {
|
||||
/* plugin onStreamComplete optional */
|
||||
}
|
||||
}
|
||||
|
||||
@@ -537,7 +537,7 @@ function sanitizeResponsesUsage(usage: unknown): unknown {
|
||||
// DeepSeek native API: map flat prompt_cache_hit_tokens into input_tokens_details
|
||||
if (
|
||||
normalized.prompt_cache_hit_tokens !== undefined &&
|
||||
!normalized.input_tokens_details?.cached_tokens
|
||||
!(toRecord(normalized.input_tokens_details) ?? {}).cached_tokens
|
||||
) {
|
||||
normalized.input_tokens_details = {
|
||||
...(normalized.input_tokens_details as Record<string, unknown> || {}),
|
||||
@@ -549,7 +549,7 @@ function sanitizeResponsesUsage(usage: unknown): unknown {
|
||||
if (
|
||||
normalized.cache_read_input_tokens !== undefined &&
|
||||
normalized.cache_read_input_tokens !== 0 &&
|
||||
!normalized.input_tokens_details?.cached_tokens
|
||||
!(toRecord(normalized.input_tokens_details) ?? {}).cached_tokens
|
||||
) {
|
||||
normalized.input_tokens_details = {
|
||||
...(normalized.input_tokens_details as Record<string, unknown> || {}),
|
||||
|
||||
@@ -304,7 +304,7 @@ function buildSerperRequest(
|
||||
url: `${config.baseUrl}${endpoint}`,
|
||||
init: {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", "X-API-Key": params.token },
|
||||
headers: { "Content-Type": "application/json", ...(params.token ? { "X-API-Key": params.token } : {}) },
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
};
|
||||
@@ -322,7 +322,7 @@ function buildBraveRequest(
|
||||
url: `${config.baseUrl}${endpoint}?${qp}`,
|
||||
init: {
|
||||
method: "GET",
|
||||
headers: { Accept: "application/json", "X-Subscription-Token": params.token },
|
||||
headers: { Accept: "application/json", ...(params.token ? { "X-Subscription-Token": params.token } : {}) },
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -348,7 +348,7 @@ function buildExaRequest(
|
||||
url: config.baseUrl,
|
||||
init: {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", "x-api-key": params.token },
|
||||
headers: { "Content-Type": "application/json", ...(params.token ? { "x-api-key": params.token } : {}) },
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -665,8 +665,6 @@ export function extractUsage(chunk) {
|
||||
chunk.usage.reasoning_tokens,
|
||||
// xAI's exact provider-reported cost (port of decolua/9router#2453, capability A).
|
||||
cost_in_usd_ticks: chunk.usage.cost_in_usd_ticks,
|
||||
cache_read_input_tokens: chunk.usage.cache_read_input_tokens,
|
||||
cache_creation_input_tokens: chunk.usage.cache_creation_input_tokens,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@ export const KNOWN_DUPLICATE_VERSIONS = new Set([
|
||||
// O stale-enforcement exige que cada reserva seja removida quando os arquivos
|
||||
// correspondentes aterrissarem na release.
|
||||
// ---------------------------------------------------------------------------
|
||||
export const KNOWN_GAPS = new Set(["026", "055", "121", "144", "145", "147", "148", "149"]); // 121: número queimado no ciclo v3.8.47 — 122 (#6909) mergeou antes e 121 nunca aterrissou (validação e2e 2026-07-12)
|
||||
export const KNOWN_GAPS = new Set(["026", "055", "121", "144", "145", "148", "149"]); // 121: número queimado no ciclo v3.8.47 — 122 (#6909) mergeou antes e 121 nunca aterrissou (validação e2e 2026-07-12)
|
||||
|
||||
function pad3(n) {
|
||||
return String(n).padStart(3, "0");
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
-- Migration 139: generic job registry (jobs + job_runs tables)
|
||||
-- Job registry (#8848): centralized periodic-job scheduling + run history.
|
||||
--
|
||||
-- jobs: one row per registered job (interval or cron), with env-flag gating
|
||||
-- job_runs: one row per execution (running/success/failure), pruned by count + age
|
||||
|
||||
CREATE TABLE IF NOT EXISTS jobs (
|
||||
id TEXT PRIMARY KEY,
|
||||
type TEXT NOT NULL DEFAULT 'interval' CHECK(type IN ('interval', 'cron')),
|
||||
cron TEXT, -- cron expression (type='cron'); NULL for interval jobs
|
||||
interval_ms INTEGER, -- interval in ms (type='interval'); NULL for cron jobs
|
||||
enabled INTEGER NOT NULL DEFAULT 1, -- 0=disabled, 1=enabled
|
||||
env_flag TEXT, -- env var name (boolean gate), e.g. 'OMNIROUTE_WARMUP_ENABLED'; NULL = no gate
|
||||
config TEXT NOT NULL DEFAULT '{}', -- JSON config (concurrency, timezone, envDefault, ...)
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS job_runs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
job_id TEXT NOT NULL REFERENCES jobs(id) ON DELETE CASCADE,
|
||||
started_at TEXT NOT NULL, -- ISO-8601, written explicitly by recordRun (no DB default)
|
||||
finished_at TEXT, -- ISO-8601; NULL while running
|
||||
status TEXT NOT NULL DEFAULT 'running', -- 'running' | 'success' | 'failure'
|
||||
error_message TEXT, -- sanitized error message (no stack trace)
|
||||
records_affected INTEGER DEFAULT 0, -- job-specific meaning (see below)
|
||||
duration_ms INTEGER, -- execution duration in ms
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_jr_job_id ON job_runs(job_id, started_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_jr_started_at ON job_runs(started_at);
|
||||
|
||||
-- Built-in job registration (idempotent - INSERT OR IGNORE).
|
||||
-- env_flag = NULL means "no registry-level boolean gate"; per-job disable semantics
|
||||
-- live inside the handler itself (see token_health_check wrapper).
|
||||
-- 'warmup' is seeded disabled because its handler is not part of this change.
|
||||
-- startAll() filters on enabled before it looks for a handler, so a disabled row
|
||||
-- stays quiet instead of warning on every boot; the change that brings the warmup
|
||||
-- handler flips it on.
|
||||
INSERT OR IGNORE INTO jobs (id, type, cron, interval_ms, enabled, env_flag, config) VALUES
|
||||
('budget_reset', 'interval', NULL, 600000, 1, NULL, '{}'),
|
||||
('warmup', 'cron', '0 7 * * *', NULL, 0, 'OMNIROUTE_WARMUP_ENABLED', '{"timezone":"America/Los_Angeles","envDefault":false}'),
|
||||
('token_health_check', 'interval', NULL, 60000, 1, NULL, '{}');
|
||||
|
||||
-- records_affected semantics:
|
||||
-- budget_reset = number of budget records reset (UPDATE ... SET budget_used=0 row count)
|
||||
-- warmup = number of connections attempted for warmup
|
||||
-- token_health_check = number of connections swept by the health check
|
||||
@@ -1,7 +1,7 @@
|
||||
import { getDbInstance } from "./core";
|
||||
import { invalidateDbCache } from "./readCache";
|
||||
|
||||
export type ModelCapabilityOverrideKey = "max_input_tokens" | "max_output_tokens";
|
||||
export type ModelCapabilityOverrideKey = "max_input_tokens" | "max_output_tokens" | "max_token";
|
||||
|
||||
export interface ModelCapabilityOverride {
|
||||
provider: string;
|
||||
@@ -21,7 +21,7 @@ interface OverrideRow {
|
||||
}
|
||||
|
||||
function isSupportedKey(value: unknown): value is ModelCapabilityOverrideKey {
|
||||
return value === "max_input_tokens" || value === "max_output_tokens";
|
||||
return value === "max_input_tokens" || value === "max_output_tokens" || value === "max_token";
|
||||
}
|
||||
|
||||
function isPositiveInteger(value: unknown): value is number {
|
||||
|
||||
@@ -222,7 +222,7 @@ export async function executeWebSearch(
|
||||
.filter((provider) => supportsSearchType(provider, searchType))
|
||||
.sort((a, b) => a.costPerQuery - b.costPerQuery)
|
||||
.map((provider) => provider.id)
|
||||
.filter((providerId) => providerId !== providerConfig.id);
|
||||
.filter((providerId) => providerId !== providerConfig!.id);
|
||||
|
||||
for (const providerId of otherIds) {
|
||||
const creds = await resolveSearchCredentials(providerId);
|
||||
|
||||
@@ -1179,6 +1179,7 @@ export const APIKEY_PROVIDERS_GATEWAYS = {
|
||||
authHint: "Get your Regolo API key from regolo.ai, then paste it here as a Bearer token.",
|
||||
apiHint:
|
||||
"OpenAI-compatible endpoint at https://api.regolo.ai/v1 with dynamic model discovery (19 models).",
|
||||
},
|
||||
"naga-ac": {
|
||||
id: "naga-ac",
|
||||
alias: "naga",
|
||||
@@ -1195,19 +1196,4 @@ export const APIKEY_PROVIDERS_GATEWAYS = {
|
||||
authHint:
|
||||
"Get API key at naga.ac — Google/GitHub/Discord signup available.",
|
||||
},
|
||||
chatanywhere: {
|
||||
id: "chatanywhere",
|
||||
alias: "chtany",
|
||||
name: "ChatAnywhere",
|
||||
icon: "chat",
|
||||
color: "#10B981",
|
||||
textIcon: "CA",
|
||||
website: "https://api.chatanywhere.tech",
|
||||
hasFree: true,
|
||||
freeNote:
|
||||
"Free tier: 5 req/day for GPT-5/4o/4.1, 30/day DeepSeek, 200/day gpt-4o-mini. Personal non-commercial use only — see chatanywhere/GPT_API_free. Requires GitHub-account-gated API key.",
|
||||
passthroughModels: true,
|
||||
authHint:
|
||||
"Get free API key at api.chatanywhere.tech — requires GitHub account signup.",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -6,9 +6,7 @@
|
||||
import { test, after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const { registerHook, unregisterHook } = await import("../../src/lib/plugins/hooks.ts");
|
||||
const { runPluginOnResponseHook } =
|
||||
await import("../../open-sse/handlers/chatCore/pluginOnResponse.ts");
|
||||
const { registerHook, unregisterHook } = await import("../../src/lib/plugins/hooks.ts");const { runPluginOnResponseHook, runPluginOnStreamCompleteHook } = await import("../../open-sse/handlers/chatCore/pluginOnResponse.ts");
|
||||
|
||||
async function waitFor(pred: () => boolean, timeoutMs = 2000): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
@@ -19,6 +17,7 @@ async function waitFor(pred: () => boolean, timeoutMs = 2000): Promise<void> {
|
||||
|
||||
after(() => {
|
||||
unregisterHook("onResponse", "test-onresponse-plugin");
|
||||
unregisterHook("onStreamComplete", "test-onstreamcomplete-plugin");
|
||||
});
|
||||
|
||||
test("no registered hooks → resolves without throwing (no-op)", async () => {
|
||||
@@ -143,3 +142,140 @@ test("a throwing hook never rejects the caller (fail-open)", async () => {
|
||||
);
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
});
|
||||
|
||||
// ── onStreamComplete hook tests (#9571) ──
|
||||
|
||||
test("onStreamComplete: no registered hooks resolves without throwing (no-op)", async () => {
|
||||
const start = Date.now();
|
||||
await assert.doesNotReject(
|
||||
runPluginOnStreamCompleteHook({
|
||||
status: 200,
|
||||
usage: { prompt_tokens: 10, completion_tokens: 20 },
|
||||
ttft: 150,
|
||||
model: "gpt-4",
|
||||
provider: "openai",
|
||||
errorCode: undefined,
|
||||
startTime: start - 500,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("onStreamComplete: registered hook receives usage + timing payload", async () => {
|
||||
let captured: Record<string, unknown> | undefined;
|
||||
registerHook(
|
||||
"onStreamComplete",
|
||||
"test-onstreamcomplete-plugin",
|
||||
async (payload: Record<string, unknown>) => {
|
||||
captured = payload;
|
||||
}
|
||||
);
|
||||
|
||||
const startTime = Date.now() - 500;
|
||||
await runPluginOnStreamCompleteHook({
|
||||
status: 200,
|
||||
usage: { prompt_tokens: 42, completion_tokens: 100, reasoning_tokens: 5 },
|
||||
ttft: 200,
|
||||
model: "claude-3-opus",
|
||||
provider: "anthropic",
|
||||
errorCode: undefined,
|
||||
startTime,
|
||||
});
|
||||
|
||||
await waitFor(() => captured !== undefined);
|
||||
assert.ok(captured, "expected onStreamComplete hook to be invoked");
|
||||
|
||||
// payload shape: status, usage, timing, model, provider
|
||||
assert.equal(captured!.status, 200);
|
||||
assert.ok(captured!.usage, "usage should be present");
|
||||
assert.equal((captured!.usage as Record<string, number>).prompt_tokens, 42);
|
||||
assert.equal((captured!.usage as Record<string, number>).completion_tokens, 100);
|
||||
assert.equal((captured!.usage as Record<string, number>).reasoning_tokens, 5);
|
||||
|
||||
assert.ok(captured!.timing, "timing should be present");
|
||||
const timing = captured!.timing as Record<string, number>;
|
||||
assert.equal(timing.ttft, 200);
|
||||
assert.ok(timing.latencyMs > 450, "latencyMs should be near 500");
|
||||
|
||||
assert.equal(captured!.model, "claude-3-opus");
|
||||
assert.equal(captured!.provider, "anthropic");
|
||||
assert.equal(captured!.errorCode, undefined);
|
||||
});
|
||||
|
||||
test("onStreamComplete: payload includes cache token fields when present", async () => {
|
||||
let captured: Record<string, unknown> | undefined;
|
||||
registerHook(
|
||||
"onStreamComplete",
|
||||
"test-onstreamcomplete-plugin",
|
||||
async (payload: Record<string, unknown>) => {
|
||||
captured = payload;
|
||||
}
|
||||
);
|
||||
|
||||
await runPluginOnStreamCompleteHook({
|
||||
status: 200,
|
||||
usage: {
|
||||
prompt_tokens: 50,
|
||||
completion_tokens: 30,
|
||||
cache_read_input_tokens: 20,
|
||||
cache_creation_input_tokens: 10,
|
||||
},
|
||||
ttft: 100,
|
||||
model: "gpt-4",
|
||||
provider: "openai",
|
||||
errorCode: undefined,
|
||||
startTime: Date.now(),
|
||||
});
|
||||
|
||||
await waitFor(() => captured !== undefined);
|
||||
assert.ok(captured);
|
||||
const usage = captured!.usage as Record<string, number>;
|
||||
assert.equal(usage.cache_read_input_tokens, 20);
|
||||
assert.equal(usage.cache_creation_input_tokens, 10);
|
||||
});
|
||||
|
||||
test("onStreamComplete: throwing hook never rejects the caller (fail-open)", async () => {
|
||||
registerHook("onStreamComplete", "test-onstreamcomplete-plugin", async () => {
|
||||
throw new Error("stream-complete-boom");
|
||||
});
|
||||
|
||||
await assert.doesNotReject(
|
||||
runPluginOnStreamCompleteHook({
|
||||
status: 500,
|
||||
usage: undefined,
|
||||
ttft: undefined,
|
||||
model: "gpt-4",
|
||||
provider: "openai",
|
||||
errorCode: "upstream_error",
|
||||
startTime: Date.now(),
|
||||
})
|
||||
);
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
});
|
||||
|
||||
test("onStreamComplete: errorCode is passed through when provided", async () => {
|
||||
let captured: Record<string, unknown> | undefined;
|
||||
registerHook(
|
||||
"onStreamComplete",
|
||||
"test-onstreamcomplete-plugin",
|
||||
async (payload: Record<string, unknown>) => {
|
||||
captured = payload;
|
||||
}
|
||||
);
|
||||
|
||||
await runPluginOnStreamCompleteHook({
|
||||
status: 502,
|
||||
usage: undefined,
|
||||
ttft: undefined,
|
||||
model: "grok-3",
|
||||
provider: "xai",
|
||||
errorCode: "upstream_timeout",
|
||||
startTime: Date.now(),
|
||||
});
|
||||
|
||||
await waitFor(() => captured !== undefined);
|
||||
assert.ok(captured);
|
||||
assert.equal(captured!.status, 502);
|
||||
assert.equal(captured!.errorCode, "upstream_timeout");
|
||||
assert.equal(captured!.model, "grok-3");
|
||||
assert.equal(captured!.provider, "xai");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user