Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions open-sse/config/providers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand Down
30 changes: 30 additions & 0 deletions open-sse/config/providers/registry/oneminai/index.ts
Original file line number Diff line number Diff line change
@@ -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" }],
};
2 changes: 2 additions & 0 deletions open-sse/executors/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()),
Expand Down
313 changes: 313 additions & 0 deletions open-sse/executors/oneminai.ts
Original file line number Diff line number Diff line change
@@ -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;
5 changes: 5 additions & 0 deletions src/lib/providers/staticModels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions src/lib/providers/validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ import {
validateRekaProvider,
validateMaritalkProvider,
validateNlpCloudProvider,
validateOneMinAiProvider,
validateRunwayProvider,
validateNousResearchProvider,
validatePoeProvider,
Expand Down Expand Up @@ -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,
Expand Down
Loading