mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-27 01:22:10 +03:00
feat(providers): add 1min.ai provider (#11631)
Merged via /merge-batch (lote 2026-08-26 batch 2, v3.8.51). Boarded no worktree combinado junto com outras ~20 PRs; validação única: typecheck/complexity/cognitive-complexity/changelog-integrity verdes, file-size rebaseado onde necessário (crescimento legítimo), lint com os mesmos 228 achados pré-existentes confirmados via sonda contra o tip puro (não introduzidos por este lote), e 292 testes focados (unit) + 18 (vitest) passando. Obrigado pela contribuição.
This commit is contained in:
@@ -63,6 +63,7 @@ import { nubeProvider } from "./registry/nube/index.ts";
|
||||
import { clinepassProvider } from "./registry/clinepass/index.ts";
|
||||
import { sparkdeskProvider } from "./registry/sparkdesk/index.ts";
|
||||
import { nlpcloudProvider } from "./registry/nlpcloud/index.ts";
|
||||
import { oneminaiProvider } from "./registry/oneminai/index.ts";
|
||||
import { nvidiaProvider } from "./registry/nvidia/index.ts";
|
||||
import { api_airforceProvider } from "./registry/api-airforce/index.ts";
|
||||
import { mistralProvider } from "./registry/mistral/index.ts";
|
||||
@@ -332,6 +333,7 @@ export const REGISTRY: Record<string, RegistryEntry> = {
|
||||
clinepass: clinepassProvider,
|
||||
sparkdesk: sparkdeskProvider,
|
||||
nlpcloud: nlpcloudProvider,
|
||||
oneminai: oneminaiProvider,
|
||||
nvidia: nvidiaProvider,
|
||||
"api-airforce": api_airforceProvider,
|
||||
mistral: mistralProvider,
|
||||
|
||||
30
open-sse/config/providers/registry/oneminai/index.ts
Normal file
30
open-sse/config/providers/registry/oneminai/index.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import type { RegistryEntry } from "../../shared.ts";
|
||||
|
||||
// 1min.ai (docs.1min.ai) — a chat aggregator exposing many upstream models
|
||||
// through one custom API. Not OpenAI-compatible at the wire level (single
|
||||
// `promptObject.prompt` string instead of a `messages` array, real SSE with
|
||||
// event:/data: framing instead of raw text deltas, "API-KEY" auth header
|
||||
// instead of Authorization: Bearer) — see open-sse/executors/oneminai.ts for
|
||||
// the request/response translation. `format: "openai"` here describes the
|
||||
// client-facing surface OmniRoute exposes, not 1min.ai's actual wire format.
|
||||
export const oneminaiProvider: RegistryEntry = {
|
||||
id: "oneminai",
|
||||
alias: "1min",
|
||||
format: "openai",
|
||||
executor: "default",
|
||||
baseUrl: "https://api.1min.ai/api/chat-with-ai",
|
||||
authType: "apikey",
|
||||
authHeader: "api-key",
|
||||
// The model catalog is loaded dynamically per-account/plan on 1min.ai's own
|
||||
// dashboard rather than published as a stable public list, so only the
|
||||
// model shown in every one of 1min.ai's own docs examples is statically
|
||||
// catalogued; passthroughModels lets any other slug the account has access
|
||||
// to be used by id.
|
||||
passthroughModels: true,
|
||||
liveCatalogAuthoritative: false,
|
||||
// No tool/function-calling, JSON mode, or vision support is wired up by the
|
||||
// executor's translation (1min.ai's attachments.images/files feature would
|
||||
// need separate Asset API upload plumbing this provider doesn't implement).
|
||||
unsupportedParams: ["tools", "tool_choice", "functions", "function_call", "response_format"],
|
||||
models: [{ id: "gpt-4o-mini", name: "GPT-4o Mini" }],
|
||||
};
|
||||
@@ -60,6 +60,8 @@ const lazyExecutors: Record<string, () => Promise<BaseExecutor>> = {
|
||||
gitlab: () => import("./gitlab.ts").then((m) => new m.GitlabExecutor()),
|
||||
"gitlab-duo": () => import("./gitlab.ts").then((m) => new m.GitlabExecutor("gitlab-duo")),
|
||||
nlpcloud: () => import("./nlpcloud.ts").then((m) => new m.NlpCloudExecutor()),
|
||||
oneminai: () => import("./oneminai.ts").then((m) => new m.OneMinAiExecutor()),
|
||||
"1min": () => import("./oneminai.ts").then((m) => new m.OneMinAiExecutor()), // Alias
|
||||
pollinations: () => import("./pollinations.ts").then((m) => new m.PollinationsExecutor()),
|
||||
pol: () => import("./pollinations.ts").then((m) => new m.PollinationsExecutor()), // Alias
|
||||
"cloudflare-ai": () => import("./cloudflare-ai.ts").then((m) => new m.CloudflareAIExecutor()),
|
||||
|
||||
313
open-sse/executors/oneminai.ts
Normal file
313
open-sse/executors/oneminai.ts
Normal file
@@ -0,0 +1,313 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
import {
|
||||
BaseExecutor,
|
||||
mergeUpstreamExtraHeaders,
|
||||
type ExecuteInput,
|
||||
type ProviderCredentials,
|
||||
} from "./base.ts";
|
||||
import { PROVIDERS } from "../config/constants.ts";
|
||||
import { buildErrorBody } from "../utils/error.ts";
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
type OpenAIMessage = {
|
||||
role?: string;
|
||||
content?: unknown;
|
||||
};
|
||||
|
||||
const CHAT_URL = "https://api.1min.ai/api/chat-with-ai";
|
||||
const ROLE_LABELS: Record<string, string> = {
|
||||
system: "System",
|
||||
developer: "System",
|
||||
user: "User",
|
||||
assistant: "Assistant",
|
||||
};
|
||||
|
||||
function asRecord(value: unknown): JsonRecord {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {};
|
||||
}
|
||||
|
||||
function extractTextContent(content: unknown): string {
|
||||
if (typeof content === "string") return content;
|
||||
if (!Array.isArray(content)) return "";
|
||||
return content
|
||||
.map((part) => {
|
||||
if (!part || typeof part !== "object") return "";
|
||||
const item = part as Record<string, unknown>;
|
||||
return item.type === "text" && typeof item.text === "string" ? item.text : "";
|
||||
})
|
||||
.filter((text) => text.length > 0)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* 1min.ai's Chat with AI API takes one `promptObject.prompt` string, not an
|
||||
* OpenAI `messages` array — multi-turn context is normally carried server-side
|
||||
* via `promptObject.conversationId` (see docs.1min.ai/docs/api/chat-with-ai-api),
|
||||
* which requires a prior POST /api/conversations call and a stable conversation
|
||||
* identity that stateless OpenAI-compatible clients don't provide. Rather than
|
||||
* half-implement that, a single user message passes through unchanged and
|
||||
* multi-turn history is flattened into a labeled transcript.
|
||||
*/
|
||||
export function buildPrompt(messages: OpenAIMessage[] | undefined): string {
|
||||
const list = Array.isArray(messages) ? messages : [];
|
||||
if (list.length === 1 && list[0]?.role === "user") {
|
||||
return extractTextContent(list[0].content);
|
||||
}
|
||||
return list
|
||||
.map((message) => {
|
||||
const role = String(message?.role || "user").toLowerCase();
|
||||
const text = extractTextContent(message?.content);
|
||||
const label = ROLE_LABELS[role] || role;
|
||||
return `${label}: ${text}`;
|
||||
})
|
||||
.filter((line) => line.length > 0)
|
||||
.join("\n\n");
|
||||
}
|
||||
|
||||
function buildSseChunk(data: unknown): string {
|
||||
return `data: ${JSON.stringify(data)}\n\n`;
|
||||
}
|
||||
|
||||
function buildOpenAiJsonCompletion(content: string, model: string, id: string, created: number): Response {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
id,
|
||||
object: "chat.completion",
|
||||
created,
|
||||
model,
|
||||
choices: [{ index: 0, message: { role: "assistant", content }, finish_reason: "stop" }],
|
||||
// 1min.ai's response shape carries no token-usage fields.
|
||||
usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
|
||||
function toOpenAiErrorResponse(status: number, message: string, upstreamDetails?: unknown): Response {
|
||||
return new Response(JSON.stringify(buildErrorBody(status, message, upstreamDetails)), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse 1min.ai's real Server-Sent Events (event: content|result|done|error,
|
||||
* data: {...}) from the upstream Response body and re-emit them as standard
|
||||
* OpenAI chat.completion.chunk SSE.
|
||||
*/
|
||||
function translateSseStream(upstreamBody: ReadableStream<Uint8Array>, model: string, id: string, created: number): ReadableStream<Uint8Array> {
|
||||
const decoder = new TextDecoder();
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
return new ReadableStream<Uint8Array>({
|
||||
async start(controller) {
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
buildSseChunk({
|
||||
id,
|
||||
object: "chat.completion.chunk",
|
||||
created,
|
||||
model,
|
||||
choices: [{ index: 0, delta: { role: "assistant" }, finish_reason: null }],
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
const reader = upstreamBody.getReader();
|
||||
let buffer = "";
|
||||
let finished = false;
|
||||
|
||||
const finish = () => {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
buildSseChunk({
|
||||
id,
|
||||
object: "chat.completion.chunk",
|
||||
created,
|
||||
model,
|
||||
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
|
||||
})
|
||||
)
|
||||
);
|
||||
controller.enqueue(encoder.encode("data: [DONE]\n\n"));
|
||||
controller.close();
|
||||
};
|
||||
|
||||
const emitContent = (text: string) => {
|
||||
if (!text) return;
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
buildSseChunk({
|
||||
id,
|
||||
object: "chat.completion.chunk",
|
||||
created,
|
||||
model,
|
||||
choices: [{ index: 0, delta: { content: text }, finish_reason: null }],
|
||||
})
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
// SSE event framing: "event:"/"data:" lines, blank-line separated records.
|
||||
const processEvent = (eventText: string) => {
|
||||
let eventType = "message";
|
||||
const dataLines: string[] = [];
|
||||
for (const rawLine of eventText.split("\n")) {
|
||||
if (rawLine.startsWith("event:")) {
|
||||
eventType = rawLine.slice(6).trim();
|
||||
} else if (rawLine.startsWith("data:")) {
|
||||
dataLines.push(rawLine.slice(5).trim());
|
||||
}
|
||||
}
|
||||
const data = dataLines.join("\n");
|
||||
if (eventType === "content") {
|
||||
try {
|
||||
const parsed = asRecord(JSON.parse(data));
|
||||
if (typeof parsed.content === "string") emitContent(parsed.content);
|
||||
} catch {
|
||||
// Ignore malformed content events rather than surfacing partial JSON.
|
||||
}
|
||||
} else if (eventType === "error") {
|
||||
emitContent(`\n[1min.ai error: ${data}]`);
|
||||
finish();
|
||||
} else if (eventType === "done") {
|
||||
finish();
|
||||
}
|
||||
// "result" carries the final full aiRecord, redundant with the content
|
||||
// events already streamed — intentionally ignored.
|
||||
};
|
||||
|
||||
try {
|
||||
while (!finished) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
let separatorIndex = buffer.indexOf("\n\n");
|
||||
while (separatorIndex !== -1) {
|
||||
processEvent(buffer.slice(0, separatorIndex));
|
||||
buffer = buffer.slice(separatorIndex + 2);
|
||||
separatorIndex = buffer.indexOf("\n\n");
|
||||
}
|
||||
}
|
||||
if (!finished && buffer.trim()) processEvent(buffer);
|
||||
finish();
|
||||
} catch (error) {
|
||||
controller.error(error);
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export class OneMinAiExecutor extends BaseExecutor {
|
||||
constructor() {
|
||||
super("oneminai", PROVIDERS["oneminai"] || { format: "openai", baseUrl: CHAT_URL });
|
||||
}
|
||||
|
||||
buildUrl(_model: string, stream: boolean): string {
|
||||
return stream ? `${CHAT_URL}?isStreaming=true` : CHAT_URL;
|
||||
}
|
||||
|
||||
buildHeaders(credentials: ProviderCredentials | null): Record<string, string> {
|
||||
const key = credentials?.apiKey || credentials?.accessToken || "";
|
||||
return {
|
||||
"Content-Type": "application/json",
|
||||
"API-KEY": key,
|
||||
};
|
||||
}
|
||||
|
||||
transformRequest(model: string, body: unknown): JsonRecord {
|
||||
const payload = asRecord(body);
|
||||
const messages = Array.isArray(payload.messages) ? (payload.messages as OpenAIMessage[]) : [];
|
||||
return {
|
||||
type: "UNIFY_CHAT_WITH_AI",
|
||||
model,
|
||||
promptObject: { prompt: buildPrompt(messages) },
|
||||
};
|
||||
}
|
||||
|
||||
async execute({ model, body, stream, credentials, signal, upstreamExtraHeaders }: ExecuteInput) {
|
||||
const url = this.buildUrl(model, stream);
|
||||
const headers = this.buildHeaders(credentials);
|
||||
mergeUpstreamExtraHeaders(headers, upstreamExtraHeaders);
|
||||
const payload = this.transformRequest(model, body);
|
||||
|
||||
const id = `chatcmpl-oneminai-${randomUUID()}`;
|
||||
const created = Math.floor(Date.now() / 1000);
|
||||
|
||||
try {
|
||||
this.assertOutboundUrlAllowed(url);
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(payload),
|
||||
signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
let message = `1min.ai API failed with status ${response.status}`;
|
||||
try {
|
||||
const parsed = asRecord(JSON.parse(errorText));
|
||||
const err = asRecord(parsed.error);
|
||||
if (typeof err.message === "string") message = err.message;
|
||||
} catch {
|
||||
if (errorText) message = errorText;
|
||||
}
|
||||
return {
|
||||
response: toOpenAiErrorResponse(response.status, message),
|
||||
url,
|
||||
headers,
|
||||
transformedBody: payload,
|
||||
};
|
||||
}
|
||||
|
||||
if (stream) {
|
||||
if (!response.body) {
|
||||
return {
|
||||
response: toOpenAiErrorResponse(502, "1min.ai returned an empty stream"),
|
||||
url,
|
||||
headers,
|
||||
transformedBody: payload,
|
||||
};
|
||||
}
|
||||
return {
|
||||
response: new Response(translateSseStream(response.body, model, id, created), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "text/event-stream" },
|
||||
}),
|
||||
url,
|
||||
headers,
|
||||
transformedBody: payload,
|
||||
};
|
||||
}
|
||||
|
||||
const json = asRecord(await response.json());
|
||||
const aiRecord = asRecord(json.aiRecord);
|
||||
const detail = asRecord(aiRecord.aiRecordDetail);
|
||||
const resultObject = Array.isArray(detail.resultObject) ? detail.resultObject : [];
|
||||
const content = resultObject.filter((part): part is string => typeof part === "string").join("");
|
||||
|
||||
return {
|
||||
response: buildOpenAiJsonCompletion(content, model, id, created),
|
||||
url,
|
||||
headers,
|
||||
transformedBody: payload,
|
||||
};
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error || "Unknown error");
|
||||
return {
|
||||
response: toOpenAiErrorResponse(502, `1min.ai fetch error: ${message}`),
|
||||
url,
|
||||
headers,
|
||||
transformedBody: payload,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default OneMinAiExecutor;
|
||||
@@ -60,6 +60,11 @@ const STATIC_MODEL_PROVIDERS: Record<string, () => Array<{ id: string; name: str
|
||||
id: model.id,
|
||||
name: model.name || model.id,
|
||||
})),
|
||||
oneminai: () =>
|
||||
getModelsByProviderId("oneminai").map((model) => ({
|
||||
id: model.id,
|
||||
name: model.name || model.id,
|
||||
})),
|
||||
qoder: () => getStaticQoderModels(),
|
||||
// Non-LLM providers with no /v1/models endpoint — expose their selectable
|
||||
// capability ids as a static catalog so the model-import step shows a usable
|
||||
|
||||
@@ -76,6 +76,7 @@ import {
|
||||
validateRekaProvider,
|
||||
validateMaritalkProvider,
|
||||
validateNlpCloudProvider,
|
||||
validateOneMinAiProvider,
|
||||
validateRunwayProvider,
|
||||
validateNousResearchProvider,
|
||||
validatePoeProvider,
|
||||
@@ -297,6 +298,7 @@ export async function validateProviderApiKey({ provider, apiKey, providerSpecifi
|
||||
reka: validateRekaProvider,
|
||||
maritalk: validateMaritalkProvider,
|
||||
nlpcloud: validateNlpCloudProvider,
|
||||
oneminai: validateOneMinAiProvider,
|
||||
runwayml: validateRunwayProvider,
|
||||
snowflake: validateSnowflakeProvider,
|
||||
gigachat: validateGigachatProvider,
|
||||
|
||||
@@ -525,6 +525,58 @@ export async function validateNlpCloudProvider({ apiKey, providerSpecificData =
|
||||
return { valid: false, error: "Connection failed while testing NLP Cloud" };
|
||||
}
|
||||
|
||||
export async function validateOneMinAiProvider({ apiKey, providerSpecificData = {} }: any) {
|
||||
const modelId =
|
||||
typeof providerSpecificData.validationModelId === "string" &&
|
||||
providerSpecificData.validationModelId.trim()
|
||||
? providerSpecificData.validationModelId.trim()
|
||||
: "gpt-4o-mini";
|
||||
|
||||
try {
|
||||
const response = await validationWrite("https://api.1min.ai/api/chat-with-ai", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", "API-KEY": apiKey },
|
||||
body: JSON.stringify({
|
||||
type: "UNIFY_CHAT_WITH_AI",
|
||||
model: modelId,
|
||||
promptObject: { prompt: "test" },
|
||||
}),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
return { valid: true, error: null, method: "oneminai_chat_with_ai" };
|
||||
}
|
||||
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
return { valid: false, error: "Invalid API key" };
|
||||
}
|
||||
|
||||
if (response.status === 429) {
|
||||
return {
|
||||
valid: true,
|
||||
error: null,
|
||||
method: "oneminai_chat_with_ai",
|
||||
warning: "Rate limited, but credentials are valid",
|
||||
};
|
||||
}
|
||||
|
||||
// 400/422 with a valid key still means the key authenticated — 1min.ai
|
||||
// rejects an unrecognized/unauthorized model this same way as a bad
|
||||
// request body, so a validation-shaped 4xx is treated as "key is valid".
|
||||
if (response.status === 400 || response.status === 422) {
|
||||
return { valid: true, error: null, method: "oneminai_chat_with_ai" };
|
||||
}
|
||||
|
||||
if (response.status >= 500) {
|
||||
return { valid: false, error: `Provider unavailable (${response.status})` };
|
||||
}
|
||||
} catch (error: any) {
|
||||
return toValidationErrorResult(error);
|
||||
}
|
||||
|
||||
return { valid: false, error: "Connection failed while testing 1min.ai" };
|
||||
}
|
||||
|
||||
export async function validateRunwayProvider({ apiKey, providerSpecificData = {} }: any) {
|
||||
const baseUrl = normalizeRunwayBaseUrl(providerSpecificData.baseUrl);
|
||||
|
||||
|
||||
@@ -3,6 +3,22 @@
|
||||
* Pure data; merged by apikey/index.ts via spread (god-file decomposition; semantic split).
|
||||
*/
|
||||
export const APIKEY_PROVIDERS_GATEWAYS = {
|
||||
// 1min.ai (https://docs.1min.ai) — multi-model chat aggregator with its own
|
||||
// custom API (single `prompt` string + real SSE, not OpenAI-compatible).
|
||||
// OmniRoute's oneminai executor translates both directions.
|
||||
oneminai: {
|
||||
id: "oneminai",
|
||||
alias: "1min",
|
||||
name: "1min.AI",
|
||||
icon: "hub",
|
||||
color: "#6366F1",
|
||||
textIcon: "1M",
|
||||
website: "https://1min.ai",
|
||||
authHint: "Create an API key at https://docs.1min.ai/docs/api/create-api-key, then paste it here.",
|
||||
apiHint:
|
||||
"1min.ai uses a proprietary chat API (single prompt string + SSE) instead of OpenAI chat/completions. OmniRoute flattens OpenAI messages into a labeled prompt and translates the SSE stream.",
|
||||
passthroughModels: true,
|
||||
},
|
||||
// Cheaper Inference (https://cheaperinference.com) — OSS-sponsor gateway.
|
||||
// Cost-ranked reseller of 42 upstream models (Anthropic/OpenAI/Google/Moonshot/
|
||||
// xAI/Z.AI/DeepSeek/MiniMax) behind one OpenAI-compatible surface, with a native
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
{
|
||||
"entries": {
|
||||
"1min": {
|
||||
"className": "OneMinAiExecutor",
|
||||
"configSource": "oneminai",
|
||||
"provider": "oneminai"
|
||||
},
|
||||
"9router": {
|
||||
"className": "NineRouterExecutor",
|
||||
"configSource": "<custom-config>",
|
||||
@@ -485,6 +490,11 @@
|
||||
"configSource": "<custom-config>",
|
||||
"provider": "notion-web"
|
||||
},
|
||||
"oneminai": {
|
||||
"className": "OneMinAiExecutor",
|
||||
"configSource": "oneminai",
|
||||
"provider": "oneminai"
|
||||
},
|
||||
"opencode": {
|
||||
"className": "OpencodeExecutor",
|
||||
"configSource": "opencode-zen",
|
||||
@@ -711,6 +721,6 @@
|
||||
"provider": "zai-web"
|
||||
}
|
||||
},
|
||||
"keyCount": 142,
|
||||
"keyCount": 144,
|
||||
"sharedInstances": []
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
// R0.3 GOLDEN LOCK (characterization BEFORE the ExecutorRegistry refactor):
|
||||
// freeze the full provider-id → executor mapping of open-sse/executors/index.ts —
|
||||
@@ -34,7 +35,7 @@ test.after(() => {
|
||||
// to (or removed from) the hard-coded map cannot hide from the snapshot.
|
||||
function readSpecializedKeys(): string[] {
|
||||
const src = fs.readFileSync(
|
||||
path.resolve(path.dirname(new URL(import.meta.url).pathname), "../../open-sse/executors/index.ts"),
|
||||
path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../open-sse/executors/index.ts"),
|
||||
"utf8"
|
||||
);
|
||||
const mapMatch = src.match(/const lazyExecutors[^\n]*= \{([\s\S]*?)\n\};/);
|
||||
|
||||
176
tests/unit/executor-oneminai.test.ts
Normal file
176
tests/unit/executor-oneminai.test.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { getExecutor, hasSpecializedExecutor } from "../../open-sse/executors/index.ts";
|
||||
import { OneMinAiExecutor, buildPrompt } from "../../open-sse/executors/oneminai.ts";
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
function jsonResponse(body: unknown, status = 200) {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
function sseResponse(events: string[]) {
|
||||
return new Response(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
for (const event of events) controller.enqueue(encoder.encode(event));
|
||||
controller.close();
|
||||
},
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "text/event-stream" } }
|
||||
);
|
||||
}
|
||||
|
||||
test("OneMinAiExecutor is registered in the executor index", async () => {
|
||||
assert.equal(hasSpecializedExecutor("oneminai"), true);
|
||||
assert.ok((await getExecutor("oneminai")) instanceof OneMinAiExecutor);
|
||||
assert.equal(hasSpecializedExecutor("1min"), true);
|
||||
assert.ok((await getExecutor("1min")) instanceof OneMinAiExecutor);
|
||||
});
|
||||
|
||||
test("buildPrompt passes a single user message through unchanged", () => {
|
||||
assert.equal(buildPrompt([{ role: "user", content: "Hello there" }]), "Hello there");
|
||||
});
|
||||
|
||||
test("buildPrompt flattens multi-turn history into a labeled transcript", () => {
|
||||
const prompt = buildPrompt([
|
||||
{ role: "system", content: "You are concise." },
|
||||
{ role: "user", content: "Hello" },
|
||||
{ role: "assistant", content: "Hi there!" },
|
||||
{ role: "user", content: "How are you?" },
|
||||
]);
|
||||
assert.equal(
|
||||
prompt,
|
||||
"System: You are concise.\n\nUser: Hello\n\nAssistant: Hi there!\n\nUser: How are you?"
|
||||
);
|
||||
});
|
||||
|
||||
test("OneMinAiExecutor sends UNIFY_CHAT_WITH_AI with a flattened prompt and API-KEY header, and unwraps the JSON response", async () => {
|
||||
const executor = new OneMinAiExecutor();
|
||||
const originalFetch = globalThis.fetch;
|
||||
const calls: Array<{ url: string; body: Record<string, unknown>; headers: Record<string, string> }> =
|
||||
[];
|
||||
|
||||
globalThis.fetch = async (url, init: RequestInit = {}) => {
|
||||
calls.push({
|
||||
url: String(url),
|
||||
body: JSON.parse(String(init.body || "{}")),
|
||||
headers: init.headers as Record<string, string>,
|
||||
});
|
||||
return jsonResponse({
|
||||
aiRecord: {
|
||||
model: "gpt-4o-mini",
|
||||
status: "SUCCESS",
|
||||
aiRecordDetail: {
|
||||
promptObject: { prompt: "How are you?" },
|
||||
resultObject: ["I'm doing well, thanks for asking!"],
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await executor.execute({
|
||||
model: "gpt-4o-mini",
|
||||
body: {
|
||||
messages: [
|
||||
{ role: "system", content: "You are concise." },
|
||||
{ role: "user", content: "Hello" },
|
||||
{ role: "assistant", content: "Hi there!" },
|
||||
{ role: "user", content: "How are you?" },
|
||||
],
|
||||
},
|
||||
stream: false,
|
||||
credentials: { apiKey: "1min-key" },
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
log: null,
|
||||
});
|
||||
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(calls[0].url, "https://api.1min.ai/api/chat-with-ai");
|
||||
assert.equal(calls[0].headers["API-KEY"], "1min-key");
|
||||
assert.equal(calls[0].body.type, "UNIFY_CHAT_WITH_AI");
|
||||
assert.equal(calls[0].body.model, "gpt-4o-mini");
|
||||
assert.equal(
|
||||
(calls[0].body.promptObject as { prompt: string }).prompt,
|
||||
"System: You are concise.\n\nUser: Hello\n\nAssistant: Hi there!\n\nUser: How are you?"
|
||||
);
|
||||
|
||||
const body = await result.response.json();
|
||||
assert.equal(body.object, "chat.completion");
|
||||
assert.equal(body.choices[0].message.role, "assistant");
|
||||
assert.equal(body.choices[0].message.content, "I'm doing well, thanks for asking!");
|
||||
assert.equal(body.model, "gpt-4o-mini");
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("OneMinAiExecutor requests the isStreaming=true endpoint and translates 1min.ai SSE into OpenAI chunks", async () => {
|
||||
const executor = new OneMinAiExecutor();
|
||||
const originalFetch = globalThis.fetch;
|
||||
let requestedUrl = "";
|
||||
|
||||
globalThis.fetch = async (url) => {
|
||||
requestedUrl = String(url);
|
||||
return sseResponse([
|
||||
'event: content\ndata: {"content": "Artificial intelligence is"}\n\n',
|
||||
'event: content\ndata: {"content": " a branch of computer science."}\n\n',
|
||||
'event: done\ndata: {"message": "Stream completed"}\n\n',
|
||||
]);
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await executor.execute({
|
||||
model: "gpt-4o-mini",
|
||||
body: { messages: [{ role: "user", content: "What is AI?" }] },
|
||||
stream: true,
|
||||
credentials: { apiKey: "1min-key" },
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
log: null,
|
||||
});
|
||||
|
||||
assert.equal(requestedUrl, "https://api.1min.ai/api/chat-with-ai?isStreaming=true");
|
||||
assert.equal(result.response.headers.get("Content-Type"), "text/event-stream");
|
||||
const text = await result.response.text();
|
||||
assert.match(text, /data: \{"id":"chatcmpl-oneminai-/);
|
||||
assert.match(text, /Artificial intelligence is/);
|
||||
assert.match(text, /a branch of computer science\./);
|
||||
assert.match(text, /"finish_reason":"stop"/);
|
||||
assert.match(text, /data: \[DONE\]/);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("OneMinAiExecutor maps upstream auth failures to OpenAI-style errors", async () => {
|
||||
const executor = new OneMinAiExecutor();
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
globalThis.fetch = async () =>
|
||||
jsonResponse(
|
||||
{ success: false, error: { code: "UNAUTHORIZED", message: "Invalid or missing API key" } },
|
||||
401
|
||||
);
|
||||
|
||||
try {
|
||||
const result = await executor.execute({
|
||||
model: "gpt-4o-mini",
|
||||
body: { messages: [{ role: "user", content: "hi" }] },
|
||||
stream: false,
|
||||
credentials: { apiKey: "bad-key" },
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
log: null,
|
||||
});
|
||||
|
||||
assert.equal(result.response.status, 401);
|
||||
const body = await result.response.json();
|
||||
assert.match(body.error.message, /Invalid or missing API key/i);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user