From 8abcd639d17954b34637c26b87529ce215ff5e9c Mon Sep 17 00:00:00 2001 From: zero-executioner Date: Wed, 26 Aug 2026 08:21:48 -0400 Subject: [PATCH] feat(providers): add 1min.ai provider (#11631) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- open-sse/config/providers/index.ts | 2 + .../providers/registry/oneminai/index.ts | 30 ++ open-sse/executors/index.ts | 2 + open-sse/executors/oneminai.ts | 313 ++++++++++++++++++ src/lib/providers/staticModels.ts | 5 + src/lib/providers/validation.ts | 2 + .../validation/audioMiscProviders.ts | 52 +++ .../constants/providers/apikey/gateways.ts | 16 + tests/snapshots/executors/executor-map.json | 12 +- tests/unit/executor-map-golden.test.ts | 3 +- tests/unit/executor-oneminai.test.ts | 176 ++++++++++ 11 files changed, 611 insertions(+), 2 deletions(-) create mode 100644 open-sse/config/providers/registry/oneminai/index.ts create mode 100644 open-sse/executors/oneminai.ts create mode 100644 tests/unit/executor-oneminai.test.ts diff --git a/open-sse/config/providers/index.ts b/open-sse/config/providers/index.ts index 4e7c66b06f..c54ea48c60 100644 --- a/open-sse/config/providers/index.ts +++ b/open-sse/config/providers/index.ts @@ -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 = { clinepass: clinepassProvider, sparkdesk: sparkdeskProvider, nlpcloud: nlpcloudProvider, + oneminai: oneminaiProvider, nvidia: nvidiaProvider, "api-airforce": api_airforceProvider, mistral: mistralProvider, diff --git a/open-sse/config/providers/registry/oneminai/index.ts b/open-sse/config/providers/registry/oneminai/index.ts new file mode 100644 index 0000000000..16a623313f --- /dev/null +++ b/open-sse/config/providers/registry/oneminai/index.ts @@ -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" }], +}; diff --git a/open-sse/executors/index.ts b/open-sse/executors/index.ts index e5b7db3da1..20155214ba 100644 --- a/open-sse/executors/index.ts +++ b/open-sse/executors/index.ts @@ -60,6 +60,8 @@ const lazyExecutors: Record Promise> = { 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()), diff --git a/open-sse/executors/oneminai.ts b/open-sse/executors/oneminai.ts new file mode 100644 index 0000000000..023f6ce425 --- /dev/null +++ b/open-sse/executors/oneminai.ts @@ -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; +type OpenAIMessage = { + role?: string; + content?: unknown; +}; + +const CHAT_URL = "https://api.1min.ai/api/chat-with-ai"; +const ROLE_LABELS: Record = { + 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; + 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, model: string, id: string, created: number): ReadableStream { + const decoder = new TextDecoder(); + const encoder = new TextEncoder(); + + return new ReadableStream({ + 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 { + 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; diff --git a/src/lib/providers/staticModels.ts b/src/lib/providers/staticModels.ts index 9c86c8cc6f..63eebdce28 100644 --- a/src/lib/providers/staticModels.ts +++ b/src/lib/providers/staticModels.ts @@ -60,6 +60,11 @@ const STATIC_MODEL_PROVIDERS: Record 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 diff --git a/src/lib/providers/validation.ts b/src/lib/providers/validation.ts index 8148173d1a..e95c2c9fc3 100644 --- a/src/lib/providers/validation.ts +++ b/src/lib/providers/validation.ts @@ -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, diff --git a/src/lib/providers/validation/audioMiscProviders.ts b/src/lib/providers/validation/audioMiscProviders.ts index 5201202e7b..c0976de088 100644 --- a/src/lib/providers/validation/audioMiscProviders.ts +++ b/src/lib/providers/validation/audioMiscProviders.ts @@ -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); diff --git a/src/shared/constants/providers/apikey/gateways.ts b/src/shared/constants/providers/apikey/gateways.ts index 80e63580e9..53134f4a80 100644 --- a/src/shared/constants/providers/apikey/gateways.ts +++ b/src/shared/constants/providers/apikey/gateways.ts @@ -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 diff --git a/tests/snapshots/executors/executor-map.json b/tests/snapshots/executors/executor-map.json index d3a64bf938..23d0e01b8b 100644 --- a/tests/snapshots/executors/executor-map.json +++ b/tests/snapshots/executors/executor-map.json @@ -1,5 +1,10 @@ { "entries": { + "1min": { + "className": "OneMinAiExecutor", + "configSource": "oneminai", + "provider": "oneminai" + }, "9router": { "className": "NineRouterExecutor", "configSource": "", @@ -485,6 +490,11 @@ "configSource": "", "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": [] } diff --git a/tests/unit/executor-map-golden.test.ts b/tests/unit/executor-map-golden.test.ts index e589755afc..569351d74c 100644 --- a/tests/unit/executor-map-golden.test.ts +++ b/tests/unit/executor-map-golden.test.ts @@ -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\};/); diff --git a/tests/unit/executor-oneminai.test.ts b/tests/unit/executor-oneminai.test.ts new file mode 100644 index 0000000000..a5c86a60e7 --- /dev/null +++ b/tests/unit/executor-oneminai.test.ts @@ -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; headers: Record }> = + []; + + globalThis.fetch = async (url, init: RequestInit = {}) => { + calls.push({ + url: String(url), + body: JSON.parse(String(init.body || "{}")), + headers: init.headers as Record, + }); + 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; + } +});