Files
2026-07-13 13:39:12 +08:00

213 lines
7.4 KiB
TypeScript

/**
* NineRouterExecutor — routes requests to a locally-managed 9router instance.
*
* 9router exposes both OpenAI-compatible (/v1/chat/completions) and
* Anthropic-compatible (/v1/messages) endpoints. The executor detects the
* wire shape from the request body and selects the matching endpoint so the
* response format is always consistent with what the upstream client expects.
*
* Auth: the 9router API key (nr_xxx) stored per-service, passed as a Bearer token.
* The service is local-only (loopback enforced by routeGuard.ts), so no TLS or
* identity cloaking is needed — 9router handles its own upstream auth internally.
*
* G-01: port and apiKey are re-read per request from the supervisor registry
* and DB respectively — never cached in the constructor — because rotate-key
* and update (new port) can change them between calls.
*
* G-02: when the supervisor is not running, the executor returns a 503 with
* header X-Omni-Fallback-Hint: connection_cooldown so accountFallback.ts
* applies a short 5s cooldown without tripping the provider circuit breaker.
*/
import {
BaseExecutor,
mergeUpstreamExtraHeaders,
mergeAbortSignals,
type ProviderCredentials,
type ExecuteInput,
} from "./base.ts";
import { FETCH_TIMEOUT_MS } from "../config/constants.ts";
import { buildErrorBody } from "../utils/error.ts";
import { getSupervisor } from "@/lib/services/registry";
import { getOrCreateApiKey } from "@/lib/services/apiKey";
const DEFAULT_PORT = 20130;
const DEFAULT_HOST = "127.0.0.1";
const HEALTH_CHECK_TIMEOUT_MS = 3_000;
/** Fallback hint header value that tells accountFallback.ts to use 5s cooldown, no breaker trip. */
export const NINEROUTER_FALLBACK_HINT = "connection_cooldown";
export const NINEROUTER_FALLBACK_HINT_HEADER = "X-Omni-Fallback-Hint";
export function resolveNineRouterBaseUrl(): string {
const host = process.env.NINEROUTER_HOST || DEFAULT_HOST;
const port = parseInt(process.env.NINEROUTER_PORT || String(DEFAULT_PORT), 10);
return `http://${host}:${port}`;
}
export class NineRouterExecutor extends BaseExecutor {
private readonly upstreamBaseUrl: string;
constructor(baseUrl?: string) {
const effectiveBase = baseUrl ?? resolveNineRouterBaseUrl();
super("9router", {
id: "9router",
baseUrl: `${effectiveBase}/v1/chat/completions`,
headers: { "Content-Type": "application/json" },
});
this.upstreamBaseUrl = effectiveBase;
}
buildUrl(
_model: string,
_stream: boolean,
_urlIndex = 0,
_credentials: ProviderCredentials | null = null
): string {
return `${this.upstreamBaseUrl}/v1/chat/completions`;
}
/**
* Build a 503 service_not_running Response with the fallback hint header.
* Message goes through buildErrorBody to satisfy hard rule #12 (no raw err.message).
*/
private buildServiceUnavailableResponse(message: string): Response {
const body = buildErrorBody(503, message);
body.error.code = "service_not_running";
return new Response(JSON.stringify(body), {
status: 503,
headers: {
"Content-Type": "application/json",
[NINEROUTER_FALLBACK_HINT_HEADER]: NINEROUTER_FALLBACK_HINT,
},
});
}
/**
* True when the body matches the Anthropic Messages wire shape.
* The same heuristic used by CliproxyapiExecutor — see comments there for
* the reasoning behind each signal.
*/
private isAnthropicShape(body: unknown): boolean {
if (!body || typeof body !== "object") return false;
const b = body as Record<string, unknown>;
if (b.system !== undefined) return true;
if (b.thinking !== undefined) return true;
if (
b.metadata &&
typeof b.metadata === "object" &&
(b.metadata as Record<string, unknown>).user_id !== undefined
)
return true;
const msgs = b.messages;
if (Array.isArray(msgs) && msgs.length > 0) {
const first = msgs[0] as Record<string, unknown>;
if (Array.isArray(first?.content)) return true;
}
return false;
}
private selectEndpoint(body: unknown): "/v1/messages" | "/v1/chat/completions" {
return this.isAnthropicShape(body) ? "/v1/messages" : "/v1/chat/completions";
}
buildHeaders(credentials: ProviderCredentials | null, stream = true): Record<string, string> {
const key = credentials?.apiKey ?? credentials?.accessToken;
const headers: Record<string, string> = { "Content-Type": "application/json" };
if (key) headers["Authorization"] = `Bearer ${key}`;
if (stream) headers["Accept"] = "text/event-stream";
return headers;
}
transformRequest(
model: string,
body: unknown,
_stream: boolean,
_credentials: ProviderCredentials | null
): unknown {
if (!body || typeof body !== "object") return body;
const transformed = { ...(body as Record<string, unknown>) };
if (transformed.model !== model) transformed.model = model;
return transformed;
}
async execute(input: ExecuteInput) {
// G-01: re-lookup supervisor state per request (port may change on restart/update)
const supervisor = getSupervisor("9router");
const status = supervisor?.getStatus();
if (!supervisor || status?.state !== "running") {
const stateLabel = status?.state ?? "unknown";
const msg = `9router is not running (state: ${stateLabel})`;
input.log?.warn?.("9ROUTER", msg);
return {
response: this.buildServiceUnavailableResponse(msg),
url: "",
headers: {},
transformedBody: null,
};
}
const dynamicPort = status.port;
const dynamicBaseUrl = `http://127.0.0.1:${dynamicPort}`;
// G-01: re-read apiKey per request — never cached in constructor
const apiKey = await getOrCreateApiKey("9router");
const dynamicCredentials: ProviderCredentials = { ...input.credentials, apiKey };
// G-01: strip "9router/" prefix before forwarding to upstream
const innerModel = input.model.replace(/^9router\//, "");
const endpoint = this.selectEndpoint(input.body);
const url = `${dynamicBaseUrl}${endpoint}`;
const shape = endpoint === "/v1/messages" ? "anthropic" : "openai";
const headers = this.buildHeaders(dynamicCredentials, input.stream);
const transformedBody = this.transformRequest(
innerModel,
input.body,
input.stream,
dynamicCredentials
);
mergeUpstreamExtraHeaders(headers, input.upstreamExtraHeaders ?? null);
const timeoutSignal = AbortSignal.timeout(FETCH_TIMEOUT_MS);
const combinedSignal = input.signal
? mergeAbortSignals(input.signal, timeoutSignal)
: timeoutSignal;
input.log?.info?.(
"9ROUTER",
`→ ${url} (model: ${innerModel}, shape: ${shape}, port: ${dynamicPort})`
);
const response = await fetch(url, {
method: "POST",
headers,
body: JSON.stringify(transformedBody),
signal: combinedSignal,
});
return { response, url, headers, transformedBody };
}
async healthCheck(): Promise<{ ok: boolean; latencyMs: number; error?: string }> {
const start = Date.now();
try {
const res = await fetch(`${this.upstreamBaseUrl}/api/health`, {
signal: AbortSignal.timeout(HEALTH_CHECK_TIMEOUT_MS),
});
return {
ok: res.ok,
latencyMs: Date.now() - start,
...(!res.ok ? { error: `HTTP ${res.status}` } : {}),
};
} catch (err) {
return {
ok: false,
latencyMs: Date.now() - start,
error: err instanceof Error ? err.message : String(err),
};
}
}
}
export default NineRouterExecutor;