Skip to content
Draft
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
412 changes: 412 additions & 0 deletions open-sse/handlers/ollamaSystemOne.ts

Large diffs are not rendered by default.

8 changes: 7 additions & 1 deletion open-sse/services/modelEndpointPolicy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
*/

export type ModelEndpointKind =
"chat" | "image" | "video" | "embedding" | "rerank" | "non-chat" | "unknown";
"chat" | "image" | "video" | "embedding" | "rerank" | "decision" | "non-chat" | "unknown";

export type ModelEndpointDecision = {
kind: ModelEndpointKind;
Expand All @@ -32,6 +32,9 @@ const EMBEDDING_ENDPOINTS = new Set(["embeddings", "embedding"]);
const RERANK_ENDPOINTS = new Set(["rerank", "reranking"]);
const IMAGE_ENDPOINTS = new Set(["image", "images", "images/generations"]);
const VIDEO_ENDPOINTS = new Set(["video", "videos", "videos/generations"]);
// System One decision models (Ollama Clef / Clef Flash, TypeSafe Jev): typed questions in,
// probabilities out — never a chat completion.
const DECISION_ENDPOINTS = new Set(["systemone"]);

function normalizeEndpoint(endpoint: string): string {
return endpoint.trim().toLowerCase().replace(/^\/+/, "").replace(/^v1\//, "");
Expand All @@ -58,6 +61,9 @@ function classifyExplicitEndpoints(
if (endpoints.some((endpoint) => VIDEO_ENDPOINTS.has(endpoint))) {
return { kind: "video", chatSelectable: false, reason: "explicit-endpoints" };
}
if (endpoints.some((endpoint) => DECISION_ENDPOINTS.has(endpoint))) {
return { kind: "decision", chatSelectable: false, reason: "explicit-endpoints" };
}
return { kind: "non-chat", chatSelectable: false, reason: "explicit-endpoints" };
}

Expand Down
42 changes: 42 additions & 0 deletions src/lib/providerModels/decisionOnlyChatGuard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { getModelEndpointDecision } from "@omniroute/open-sse/services/modelEndpointPolicy.ts";
import { errorResponse } from "@omniroute/open-sse/utils/error.ts";
import { getSyncedAvailableModels, type SyncedAvailableModel } from "@/lib/db/models";

type DecisionOnlyChatGuardDeps = {
getSyncedAvailableModels: (providerId: string) => Promise<SyncedAvailableModel[]>;
};

/**
* Providers whose discovery can record a System One (`decision`) model. Only these pay the
* stored-model lookup on the chat path; every other provider is untouched.
*/
const SYSTEM_ONE_CAPABLE_PROVIDERS = new Set(["ollama-local"]);

/**
* Refuse a chat call to a model that only serves `/v1/systemone`.
*
* Ollama's `/api/show` reports Clef / Clef Flash as `["vision", "decision"]` with no
* `completion`; discovery stores that as `supportedEndpoints: ["systemone"]`. Sending
* such a model a chat request only earns an upstream 400, so it is refused here, before
* any credential is selected — nothing is marked on the connection. The message reads as
* a model-scoped 400, so a combo advances to its next target instead of stopping.
*/
export async function decisionOnlyChatRejection(
modelInfo: { provider?: string | null; model?: string | null },
deps: DecisionOnlyChatGuardDeps = { getSyncedAvailableModels }
): Promise<Response | null> {
const provider = modelInfo.provider ?? "";
const model = modelInfo.model ?? "";
if (!model || !SYSTEM_ONE_CAPABLE_PROVIDERS.has(provider)) return null;

const row = (await deps.getSyncedAvailableModels(provider)).find((m) => m.id === model);
if (!row) return null;
if (getModelEndpointDecision(provider, model, row.supportedEndpoints).kind !== "decision") {
return null;
}
return errorResponse(
400,
`Model ${provider}/${model} does not support chat: it is a System One decision model; ` +
"use the System One API (POST v1/systemone)"
);
}
21 changes: 15 additions & 6 deletions src/lib/providerModels/ollamaCapabilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,17 @@ const OLLAMA_CAPABILITY_TO_ENDPOINT: Readonly<Record<string, string>> = {
completion: "chat",
embedding: "embeddings",
image: "images",
// Ollama >= 0.35 tags System One models (Nimble, Tev) with `decision`; they answer
// typed questions on `/v1/systemone`.
decision: "systemone",
};

const ENDPOINT_TO_API_FORMAT: ReadonlyArray<readonly [string, string]> = [
["chat", "chat-completions"],
["embeddings", "embeddings"],
["images", "images-generations"],
];

const MAX_CONCURRENT_SHOW_REQUESTS = 4;

function asRecord(value: unknown): JsonRecord {
Expand Down Expand Up @@ -45,15 +54,15 @@ export function applyOllamaShowCapabilities(model: unknown, showResponse: unknow
);
if (supportedEndpoints.length === 0) return record;

const apiFormat = supportedEndpoints.includes("chat")
? "chat-completions"
: supportedEndpoints.includes("embeddings")
? "embeddings"
: "images-generations";
// `systemone` has no wire format of its own, so a decision-only model keeps whatever
// apiFormat the record already carried.
const apiFormat = ENDPOINT_TO_API_FORMAT.find(([endpoint]) =>
supportedEndpoints.includes(endpoint)
)?.[1];

return {
...record,
apiFormat,
...(apiFormat ? { apiFormat } : {}),
supportedEndpoints,
...(capabilities.includes("vision") ? { supportsVision: true } : {}),
...(capabilities.includes("tools") ? { supportsTools: true } : {}),
Expand Down
11 changes: 10 additions & 1 deletion src/shared/constants/modelSupportedEndpoints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ export const MODEL_SUPPORTED_ENDPOINT_VALUES = [
"audio-speech",
"audio-transcriptions",
"images-generations",
// System One typed-decision API (`POST /v1/systemone`): choice / noul / score answers.
"systemone",
// Persisted legacy values remain valid input and normalize on write/edit.
"video",
"audio",
Expand All @@ -33,14 +35,21 @@ export function normalizeModelSupportedEndpoints(endpoints: readonly string[]):
return normalized;
}

/** A System One model that does not also serve chat is a decision model (Clef, Clef Flash). */
function isDecisionOnly(endpoints: readonly string[]): boolean {
if (!endpoints.includes("systemone")) return false;
return !endpoints.includes("chat") && !endpoints.includes("responses");
}

export function classifyModelSupportedEndpoints(endpoints: readonly string[]): {
type?: "embedding" | "rerank" | "image" | "video" | "audio";
type?: "embedding" | "rerank" | "image" | "video" | "audio" | "decision";
subtype?: "speech" | "transcription";
} {
if (endpoints.includes("embeddings")) return { type: "embedding" };
if (endpoints.includes("rerank")) return { type: "rerank" };
if (endpoints.includes("images")) return { type: "image" };
if (endpoints.includes("videos") || endpoints.includes("video")) return { type: "video" };
if (isDecisionOnly(endpoints)) return { type: "decision" };

const supportsSpeech = endpoints.includes("audio-speech");
const supportsTranscription =
Expand Down
8 changes: 7 additions & 1 deletion src/sse/services/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import {
import { commonChatGptWebRetirementResponse } from "@/lib/providers/chatgptWebRetirementResponse";
import { errorResponse } from "@omniroute/open-sse/utils/error.ts";
import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts";
import { decisionOnlyChatRejection } from "@/lib/providerModels/decisionOnlyChatGuard";

export { parseModel, stripContextWindowSuffix };

Expand Down Expand Up @@ -614,8 +615,9 @@ export async function getModelInfo(modelStr) {
}

export async function getModelInfoOrRetirementResponse(modelId: string) {
let modelInfo: Awaited<ReturnType<typeof getModelInfo>>;
try {
return await getModelInfo(modelId);
modelInfo = await getModelInfo(modelId);
} catch (error) {
if (isMicrosoftDesignerWebProviderRetiredError(error)) {
return { error: errorResponse(HTTP_STATUS.GONE, error.message) };
Expand All @@ -633,6 +635,10 @@ export async function getModelInfoOrRetirementResponse(modelId: string) {
}
throw error;
}
// A System One decision model (Clef / Clef Flash) cannot serve chat: refuse it here,
// before any credential is picked, with a 400 a combo treats as model-scoped.
const decisionOnly = await decisionOnlyChatRejection(modelInfo);
return decisionOnly ? { error: decisionOnly } : modelInfo;
}

/**
Expand Down
3 changes: 2 additions & 1 deletion stryker.conf.json
Original file line number Diff line number Diff line change
Expand Up @@ -570,7 +570,8 @@
"tests/unit/search-success-clears-connection-error.test.ts",
"tests/unit/secret-compare-constant-time.test.ts",
"tests/unit/version-manager-local-only.test.ts",
"tests/unit/stream-failure-after-output-no-model-lockout.test.ts"
"tests/unit/stream-failure-after-output-no-model-lockout.test.ts",
"tests/unit/ollama-systemone.test.ts"
],
"nodeArgs": [
"--import",
Expand Down
141 changes: 141 additions & 0 deletions tests/unit/ollama-systemone-decision-guard.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
/**
* Decision-only models (Ollama System One: Clef, Clef Flash) must not be served as chat.
*
* Ollama's `/api/show` reports Clef as `["vision", "decision"]` — no `completion` — and
* discovery stores that as `supportedEndpoints: ["systemone"]`. Before this guard the row
* reached `/v1/models` with no `type` (so agents listed it as a chat model) and a chat call
* went upstream and came back as a 400.
*
* Rules:
* R1 The endpoint policy classifies a systemone-only row as `decision`, not chat-selectable.
* R2 `/v1/models` classification tags a systemone-only row `type: "decision"`; a model that
* also advertises chat (Nimble, Tev) stays a chat model.
* R3 A chat call to a decision-only model is refused with a clear 400, before any upstream
* call, and the message is one a combo treats as model-scoped (advance, never stop).
* R4 Chat-capable, unknown, and non-System-One-provider models are untouched, and other
* providers never pay the stored-model lookup.
* R5 resolveModelOrError — the chokepoint for direct calls and every combo target —
* returns that 400 for a decision-only model.
*/
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";

const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-decision-guard-"));
process.env.DATA_DIR = TEST_DATA_DIR;
process.env.API_KEY_SECRET = "test-api-key-secret-decision-guard";

const core = await import("../../src/lib/db/core.ts");
const models = await import("../../src/lib/db/models.ts");
const { getModelEndpointDecision, isChatSelectableModel } =
await import("../../open-sse/services/modelEndpointPolicy.ts");
const { classifyModelSupportedEndpoints } =
await import("../../src/shared/constants/modelSupportedEndpoints.ts");
const { comboTargetDecision } =
await import("../../open-sse/services/combo/statusDecisionTable.ts");
const { decisionOnlyChatRejection } =
await import("../../src/lib/providerModels/decisionOnlyChatGuard.ts");
const { resolveModelOrError } = await import("../../src/sse/handlers/chatHelpers.ts");

test.after(() => {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
});

await models.replaceSyncedAvailableModelsForConnection("ollama-local", "conn-1", [
{ id: "clef-flash", name: "clef-flash", supportedEndpoints: ["systemone"] },
{ id: "nimble:latest", name: "nimble:latest", supportedEndpoints: ["systemone", "chat"] },
{ id: "gemma3:4b", name: "gemma3:4b", supportedEndpoints: ["chat"] },
]);

test("R1: the endpoint policy classifies a systemone-only row as a decision model", () => {
const decision = getModelEndpointDecision("ollama-local", "clef-flash", ["systemone"]);
assert.equal(decision.kind, "decision");
assert.equal(decision.chatSelectable, false);
assert.equal(
isChatSelectableModel("ollama-local", { id: "clef", supportedEndpoints: ["systemone"] }),
false
);

const both = getModelEndpointDecision("ollama-local", "nimble", ["systemone", "chat"]);
assert.equal(both.kind, "chat");
assert.equal(both.chatSelectable, true);
});

test("R2: /v1/models tags a systemone-only row as decision; chat + systemone stays chat", () => {
assert.deepEqual(classifyModelSupportedEndpoints(["systemone"]), { type: "decision" });
assert.deepEqual(classifyModelSupportedEndpoints(["systemone", "chat"]), {});
assert.deepEqual(classifyModelSupportedEndpoints(["chat"]), {});
});

test("R3: a chat call to a decision-only model is a clear, combo-advancing 400", async () => {
const response = await decisionOnlyChatRejection({
provider: "ollama-local",
model: "clef-flash",
});
assert.ok(response, "a decision-only model must be refused");
assert.equal(response.status, 400);
const body = await response.json();
const message = String(body.error?.message ?? "");
assert.match(message, /does not support chat/i);
assert.match(message, /System One API \(POST v1\/systemone\)/);
assert.equal(message.includes("at /"), false, "no stack trace in the body");
assert.equal(
comboTargetDecision(400, message),
"advance",
"a combo must move to the next target"
);
});

test("R4: chat-capable, unknown and other-provider models are untouched", async () => {
assert.equal(
await decisionOnlyChatRejection({ provider: "ollama-local", model: "nimble:latest" }),
null
);
assert.equal(
await decisionOnlyChatRejection({ provider: "ollama-local", model: "gemma3:4b" }),
null
);
assert.equal(
await decisionOnlyChatRejection({ provider: "ollama-local", model: "not-synced" }),
null
);

let lookups = 0;
const spy = async () => {
lookups += 1;
return [];
};
assert.equal(
await decisionOnlyChatRejection(
{ provider: "openai", model: "gpt-5.5" },
{ getSyncedAvailableModels: spy }
),
null
);
assert.equal(lookups, 0, "providers that cannot serve System One never pay the lookup");
});

test("R5: resolveModelOrError refuses a decision-only model before any dispatch", async () => {
const body = { model: "ollama-local/clef-flash", messages: [{ role: "user", content: "hi" }] };
const resolved = (await resolveModelOrError(
"ollama-local/clef-flash",
body,
"/v1/chat/completions"
)) as {
error?: Response;
};
assert.ok(resolved.error, "a decision-only model must not resolve for chat");
assert.equal(resolved.error.status, 400);

const chat = (await resolveModelOrError(
"ollama-local/gemma3:4b",
body,
"/v1/chat/completions"
)) as {
error?: Response;
};
assert.equal(chat.error, undefined, "a chat model still resolves");
});
Loading
Loading