mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-31 04:12:10 +03:00
Integrated into release/v3.8.0 — fixes stream readiness detection for OpenAI Responses API lifecycle events, GLM timeout, Provider Limits UI, and build output cleanup.
401 lines
13 KiB
TypeScript
401 lines
13 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
|
|
import { DefaultExecutor } from "./default.ts";
|
|
import {
|
|
applyConfiguredUserAgent,
|
|
mergeAbortSignals,
|
|
mergeUpstreamExtraHeaders,
|
|
type CountTokensInput,
|
|
type ExecuteInput,
|
|
type ProviderCredentials,
|
|
} from "./base.ts";
|
|
import {
|
|
buildGlmBaseHeaders,
|
|
buildGlmChatUrl,
|
|
buildGlmCodingHeaders,
|
|
buildGlmCountTokensUrl,
|
|
GLM_COUNT_TOKENS_TIMEOUT_MS,
|
|
type GlmTransport,
|
|
getGlmTransport,
|
|
} from "../config/glmProvider.ts";
|
|
import { applyProviderRequestDefaults } from "../services/providerRequestDefaults.ts";
|
|
import { getRotatingApiKey } from "../services/apiKeyRotator.ts";
|
|
import { CLAUDE_CLI_STAINLESS_PACKAGE_VERSION } from "../config/anthropicHeaders.ts";
|
|
import {
|
|
getRuntimeVersion,
|
|
normalizeStainlessArch,
|
|
normalizeStainlessPlatform,
|
|
} from "../config/providerHeaderProfiles.ts";
|
|
import { translateNonStreamingResponse } from "../handlers/responseTranslator.ts";
|
|
import { translateRequest } from "../translator/index.ts";
|
|
import { FORMATS } from "../translator/formats.ts";
|
|
import { createSSETransformStreamWithLogger } from "../utils/stream.ts";
|
|
import { ensureStreamReadiness } from "../utils/streamReadiness.ts";
|
|
import { STREAM_READINESS_TIMEOUT_MS } from "../config/constants.ts";
|
|
|
|
type JsonRecord = Record<string, unknown>;
|
|
type GlmExecuteResult = Awaited<ReturnType<DefaultExecutor["execute"]>> & {
|
|
targetFormat?: string;
|
|
};
|
|
|
|
function asRecord(value: unknown): JsonRecord | null {
|
|
return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : null;
|
|
}
|
|
|
|
function getEffectiveKey(credentials: ProviderCredentials): string {
|
|
const extraKeys = (credentials.providerSpecificData?.extraApiKeys as string[] | undefined) ?? [];
|
|
if (credentials.apiKey && credentials.connectionId && extraKeys.length > 0) {
|
|
return getRotatingApiKey(credentials.connectionId, credentials.apiKey, extraKeys);
|
|
}
|
|
return credentials.apiKey || credentials.accessToken || "";
|
|
}
|
|
|
|
function applyGlmRequestDefaults(body: unknown, defaults?: JsonRecord | null): unknown {
|
|
const record = asRecord(body);
|
|
if (!record || !defaults) return body;
|
|
|
|
const next = { ...(applyProviderRequestDefaults(record, defaults) as JsonRecord) };
|
|
const thinkingType = typeof defaults.thinkingType === "string" ? defaults.thinkingType : null;
|
|
|
|
if (thinkingType && next.thinking === undefined) {
|
|
next.thinking = { type: thinkingType };
|
|
} else if (thinkingType && asRecord(next.thinking)?.type === "enabled") {
|
|
next.thinking = { ...asRecord(next.thinking), type: thinkingType };
|
|
}
|
|
|
|
return next;
|
|
}
|
|
|
|
function hasTools(body: unknown): boolean {
|
|
const record = asRecord(body);
|
|
return Array.isArray(record?.tools) && record.tools.length > 0;
|
|
}
|
|
|
|
function isRetryableGlmFallbackStatus(status: number): boolean {
|
|
return status === 404 || status === 408 || status === 409 || status === 429 || status >= 500;
|
|
}
|
|
|
|
function isRetryableGlmFallbackError(error: unknown): boolean {
|
|
if (!error) return false;
|
|
const err = error instanceof Error ? error : new Error(String(error));
|
|
if (err.name === "AbortError") return false;
|
|
return true;
|
|
}
|
|
|
|
function cloneHeaders(headers: Headers): Headers {
|
|
const next = new Headers();
|
|
headers.forEach((value, key) => next.set(key, value));
|
|
return next;
|
|
}
|
|
|
|
function isJsonResponse(response: Response): boolean {
|
|
return (response.headers.get("content-type") || "").toLowerCase().includes("application/json");
|
|
}
|
|
|
|
async function translateJsonResponse(response: Response): Promise<Response> {
|
|
const parsed = await response.json().catch(() => null);
|
|
const translated = translateNonStreamingResponse(parsed, FORMATS.CLAUDE, FORMATS.OPENAI);
|
|
const headers = cloneHeaders(response.headers);
|
|
headers.set("content-type", "application/json");
|
|
headers.delete("content-length");
|
|
return new Response(JSON.stringify(translated), {
|
|
status: response.status,
|
|
statusText: response.statusText,
|
|
headers,
|
|
});
|
|
}
|
|
|
|
async function translateAnthropicJsonResponse(response: Response): Promise<Response> {
|
|
const parsed = await response.json().catch(() => null);
|
|
const translated = response.ok
|
|
? translateNonStreamingResponse(parsed, FORMATS.CLAUDE, FORMATS.OPENAI)
|
|
: translateAnthropicJsonError(parsed);
|
|
const headers = cloneHeaders(response.headers);
|
|
headers.set("content-type", "application/json");
|
|
headers.delete("content-length");
|
|
return new Response(JSON.stringify(translated), {
|
|
status: response.status,
|
|
statusText: response.statusText,
|
|
headers,
|
|
});
|
|
}
|
|
|
|
function translateAnthropicJsonError(parsed: unknown): JsonRecord {
|
|
const root = asRecord(parsed) || {};
|
|
const error = asRecord(root.error) || root;
|
|
const message =
|
|
typeof error.message === "string" && error.message.trim()
|
|
? error.message
|
|
: typeof root.message === "string" && root.message.trim()
|
|
? root.message
|
|
: "GLM Anthropic transport error";
|
|
const type =
|
|
typeof error.type === "string" && error.type.trim()
|
|
? error.type
|
|
: typeof root.type === "string" && root.type.trim()
|
|
? root.type
|
|
: "upstream_error";
|
|
|
|
return {
|
|
error: {
|
|
message,
|
|
type,
|
|
},
|
|
};
|
|
}
|
|
|
|
function translateSseResponse(response: Response, provider: string, model: string): Response {
|
|
if (!response.body) return response;
|
|
const transform = createSSETransformStreamWithLogger(
|
|
FORMATS.CLAUDE,
|
|
FORMATS.OPENAI,
|
|
provider,
|
|
null,
|
|
null,
|
|
model
|
|
);
|
|
const headers = cloneHeaders(response.headers);
|
|
headers.set("content-type", "text/event-stream");
|
|
headers.delete("content-length");
|
|
return new Response(response.body.pipeThrough(transform), {
|
|
status: response.status,
|
|
statusText: response.statusText,
|
|
headers,
|
|
});
|
|
}
|
|
|
|
export class GlmExecutor extends DefaultExecutor {
|
|
constructor(provider = "glm") {
|
|
super(provider);
|
|
}
|
|
|
|
buildUrl(
|
|
_model: string,
|
|
_stream: boolean,
|
|
_urlIndex = 0,
|
|
credentials: ProviderCredentials | null = null
|
|
) {
|
|
const primaryTransport = getGlmTransport(credentials?.providerSpecificData);
|
|
const transport =
|
|
_urlIndex === 1 ? (primaryTransport === "openai" ? "anthropic" : "openai") : primaryTransport;
|
|
return buildGlmChatUrl(credentials?.providerSpecificData, transport, this.config.baseUrl);
|
|
}
|
|
|
|
buildCountTokensUrl(_model: string, credentials: ProviderCredentials | null = null) {
|
|
return buildGlmCountTokensUrl(credentials?.providerSpecificData, this.config.baseUrl);
|
|
}
|
|
|
|
getCountTokensTimeoutMs() {
|
|
return GLM_COUNT_TOKENS_TIMEOUT_MS;
|
|
}
|
|
|
|
buildHeaders(
|
|
credentials: ProviderCredentials,
|
|
stream = true,
|
|
_clientHeaders?: Record<string, string> | null,
|
|
_model?: string,
|
|
transport: GlmTransport = getGlmTransport(credentials.providerSpecificData)
|
|
): Record<string, string> {
|
|
if (transport === "openai") {
|
|
return buildGlmCodingHeaders(getEffectiveKey(credentials), stream);
|
|
}
|
|
|
|
return {
|
|
...buildGlmBaseHeaders(getEffectiveKey(credentials), stream),
|
|
"X-Stainless-Arch": normalizeStainlessArch(),
|
|
"X-Stainless-OS": normalizeStainlessPlatform(),
|
|
"X-Stainless-Runtime-Version": getRuntimeVersion(),
|
|
"X-Stainless-Package-Version": CLAUDE_CLI_STAINLESS_PACKAGE_VERSION,
|
|
"X-Claude-Code-Session-Id": randomUUID(),
|
|
"x-client-request-id": randomUUID(),
|
|
};
|
|
}
|
|
|
|
transformRequest(
|
|
model: string,
|
|
body: unknown,
|
|
stream: boolean,
|
|
credentials: ProviderCredentials
|
|
) {
|
|
const cleanedBody = super.transformRequest(model, body, stream, credentials);
|
|
return applyGlmRequestDefaults(cleanedBody, this.config.requestDefaults as JsonRecord | null);
|
|
}
|
|
|
|
transformForTransport(
|
|
model: string,
|
|
body: unknown,
|
|
stream: boolean,
|
|
credentials: ProviderCredentials,
|
|
transport: GlmTransport
|
|
) {
|
|
const transformed = this.transformRequest(model, body, stream, credentials);
|
|
|
|
if (transport === "openai") {
|
|
const record = asRecord(transformed);
|
|
if (record && stream && hasTools(record) && record.tool_stream === undefined) {
|
|
return { ...record, tool_stream: true };
|
|
}
|
|
return transformed;
|
|
}
|
|
|
|
return translateRequest(
|
|
FORMATS.OPENAI,
|
|
FORMATS.CLAUDE,
|
|
model,
|
|
{ ...(transformed as JsonRecord), _disableToolPrefix: true },
|
|
stream,
|
|
credentials,
|
|
this.provider,
|
|
null,
|
|
{ preserveCacheControl: false }
|
|
);
|
|
}
|
|
|
|
private async executeTransport(
|
|
input: ExecuteInput,
|
|
transport: GlmTransport
|
|
): Promise<GlmExecuteResult> {
|
|
const credentials = input.credentials;
|
|
const url = buildGlmChatUrl(credentials?.providerSpecificData, transport, this.config.baseUrl);
|
|
const headers = this.buildHeaders(
|
|
credentials,
|
|
input.stream,
|
|
input.clientHeaders,
|
|
input.model,
|
|
transport
|
|
);
|
|
applyConfiguredUserAgent(headers, credentials.providerSpecificData);
|
|
mergeUpstreamExtraHeaders(headers, input.upstreamExtraHeaders);
|
|
|
|
const transformedBody = this.transformForTransport(
|
|
input.model,
|
|
input.body,
|
|
input.stream,
|
|
credentials,
|
|
transport
|
|
);
|
|
|
|
const fetchStartTimeoutMs = this.getTimeoutMs();
|
|
const timeoutController = fetchStartTimeoutMs > 0 ? new AbortController() : null;
|
|
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
|
if (timeoutController) {
|
|
timeoutId = setTimeout(() => {
|
|
const timeoutError = new Error(`Fetch timeout after ${fetchStartTimeoutMs}ms on ${url}`);
|
|
timeoutError.name = "TimeoutError";
|
|
timeoutController.abort(timeoutError);
|
|
}, fetchStartTimeoutMs);
|
|
}
|
|
|
|
const timeoutSignal = timeoutController?.signal ?? null;
|
|
const combinedSignal =
|
|
input.signal && timeoutSignal
|
|
? mergeAbortSignals(input.signal, timeoutSignal)
|
|
: input.signal || timeoutSignal;
|
|
|
|
let response: Response;
|
|
try {
|
|
response = await fetch(url, {
|
|
method: "POST",
|
|
headers,
|
|
body: JSON.stringify(transformedBody),
|
|
signal: combinedSignal || undefined,
|
|
});
|
|
} finally {
|
|
if (timeoutId) clearTimeout(timeoutId);
|
|
}
|
|
|
|
if (input.stream && response.ok) {
|
|
const readiness = await ensureStreamReadiness(response, {
|
|
timeoutMs: STREAM_READINESS_TIMEOUT_MS,
|
|
provider: this.provider,
|
|
model: input.model,
|
|
log: input.log,
|
|
});
|
|
response = readiness.response;
|
|
}
|
|
|
|
const result = { response, url, headers, transformedBody };
|
|
|
|
if (transport === "anthropic") {
|
|
const translatedResponse =
|
|
input.stream && result.response.ok
|
|
? translateSseResponse(result.response, this.provider, input.model)
|
|
: isJsonResponse(result.response)
|
|
? await translateAnthropicJsonResponse(result.response)
|
|
: result.response;
|
|
return {
|
|
...result,
|
|
response: translatedResponse,
|
|
url,
|
|
headers,
|
|
transformedBody,
|
|
targetFormat: FORMATS.OPENAI,
|
|
};
|
|
}
|
|
|
|
return {
|
|
...result,
|
|
url,
|
|
headers,
|
|
transformedBody,
|
|
targetFormat: FORMATS.OPENAI,
|
|
};
|
|
}
|
|
|
|
async execute(input: ExecuteInput): Promise<GlmExecuteResult> {
|
|
const primaryTransport = getGlmTransport(
|
|
input.credentials.providerSpecificData,
|
|
this.config.baseUrl
|
|
);
|
|
const fallbackTransport: GlmTransport = primaryTransport === "openai" ? "anthropic" : "openai";
|
|
|
|
let primaryResult: GlmExecuteResult | null = null;
|
|
try {
|
|
primaryResult = await this.executeTransport(input, primaryTransport);
|
|
if (!isRetryableGlmFallbackStatus(primaryResult.response.status)) {
|
|
return primaryResult;
|
|
}
|
|
input.log?.debug?.(
|
|
"GLM_FALLBACK",
|
|
`${primaryTransport} returned ${primaryResult.response.status}; trying ${fallbackTransport}`
|
|
);
|
|
} catch (error) {
|
|
if (!isRetryableGlmFallbackError(error)) throw error;
|
|
input.log?.debug?.(
|
|
"GLM_FALLBACK",
|
|
`${primaryTransport} error (${error instanceof Error ? error.message : String(error)}); trying ${fallbackTransport}`
|
|
);
|
|
}
|
|
|
|
try {
|
|
const fallbackResult = await this.executeTransport(input, fallbackTransport);
|
|
if (fallbackResult.response.ok || !primaryResult) {
|
|
return fallbackResult;
|
|
}
|
|
} catch (error) {
|
|
if (!primaryResult) throw error;
|
|
input.log?.debug?.(
|
|
"GLM_FALLBACK",
|
|
`${fallbackTransport} fallback failed (${error instanceof Error ? error.message : String(error)}); returning primary response`
|
|
);
|
|
}
|
|
|
|
return primaryResult;
|
|
}
|
|
|
|
async countTokens(input: CountTokensInput) {
|
|
return super.countTokens({
|
|
...input,
|
|
credentials: {
|
|
...input.credentials,
|
|
providerSpecificData: {
|
|
...(input.credentials.providerSpecificData || {}),
|
|
primaryTransport: "anthropic",
|
|
},
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
export default GlmExecutor;
|