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
19 changes: 15 additions & 4 deletions docs-site/src/content/docs/guides/combos.md
Original file line number Diff line number Diff line change
Expand Up @@ -455,10 +455,21 @@ Combo's `decisionProvider`:
- Self-hosted services receive each target/effort option as a plain description string (for
example `Target openai/gpt-5.6-sol (provider openai, model gpt-5.6-sol) with low reasoning
effort.`), because Ollama accepts only string or `null` option descriptions. TypeSafe keeps
receiving the structured option objects.
- OpenCodex offers 2–26 options to a System One-compatible row. Each target contributes one option
per offered reasoning effort; use per-target `reasoningEfforts` to trim them. Fewer than 2 or
more than 26 options fail open without a request.
receiving the structured option objects. When targeting Vercel AI Gateway (`ai-gateway.vercel.sh`),
route options include both `value` and detailed `description` fields. Other self-hosted endpoints,
including a custom `/decisions` path, keep the System One request format.
- OpenCodex offers 2–26 options to a System One-compatible row (`SELF_HOSTED_MIN_OPTIONS = 2`, `SELF_HOSTED_MAX_OPTIONS = 26`).
Each target contributes one option per offered reasoning effort; use per-target `reasoningEfforts` to trim them.
Fewer than 2 or more than 26 options fail open without a request. For Vercel AI Gateway, whose
[OpenAI-compatible Decisions reference](https://vercel.com/docs/ai-gateway/sdks-and-apis/openai-decisions)
supports 1..255 choices, the upper bound expands to 255 options (`DECISIONS_MAX_OPTIONS = 255`); OpenCodex
enforces the 2-choice minimum as local routing policy to select among candidate targets.
- Vercel AI Gateway endpoints (`https://ai-gateway.vercel.sh/v1`) automatically normalize to `/decisions`.
OpenCodex projects structured conversation context into a formatted plain-text `input` string
(`Task:`, `Signals:`, `Recent Assistant:`, `Recent Tool Call:`, and `Tool Output:`). Route answers are accepted
from object (`answers.route`) or choice-array formats, resolving from either `choice` or `value`. Probability
distributions are accepted as key-value maps or probability records; duplicate choice keys or invalid distributions
strictly fail open to the first eligible target.
- A cold model load can take tens of seconds, and an aborted decision request makes Ollama abandon the
load. Pre-warm the model and keep it resident (`OLLAMA_KEEP_ALIVE=-1`, or `keep_alive`), and raise
`decisionTimeoutMs` (1000–120000 ms, default 4000) when the service is slower than four seconds.
Expand Down
10 changes: 8 additions & 2 deletions src/adapters/openai-chat/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { EMPTY_TOOL_OUTPUT_ANNOTATION, isWhitespaceOnlyTextPartArray } from "../
import { identifyRoutedModel } from "../identity";
import { buildNonOpenAIToolCatalogNudgeForTools, shouldInjectNonOpenAIToolCatalogNudge } from "../tool-catalog-nudge";
import { peekReasoningForCall } from "../../responses/reasoning-replay-cache";
import { chatReasoningReplayPolicy } from "./reasoning-replay";
import { chatReasoningReplayPolicy, isReflectionThinkingModel } from "./reasoning-replay";
import type { ClaudeThinkingProjection } from "../../lib/claude-request-projection";
import { inlineDocumentDataUrl } from "../../responses/inline-document";
import type { OcxAssistantMessage, OcxContentPart, OcxParsedRequest, OcxProviderConfig, OcxTextContent, OcxThinkingContent, OcxToolCall } from "../../types";
Expand Down Expand Up @@ -282,9 +282,15 @@ export function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProv
const chatMsg: Record<string, unknown> = { role: "assistant" };
if (textParts.length > 0) chatMsg.content = textParts.map(p => p.text).join("");
let reasoningContent = thinkingParts.map(p => p.thinking).join("");
// Reflection/Stealth replay a placeholder even on a tool-less turn, but never on an
// empty turn inside an unfinished tool batch: that turn is dropped below, and a
// fabricated reasoning block there would split the call from its real result.
const reflectionPlaceholder = isReflectionThinkingModel(parsed.modelId)
&& chatReasoningReplayPolicy(provider, parsed.modelId).placeholder
&& !(pendingToolCalls.length > 0 && !chatMsg.content && toolCalls.length === 0 && thinkingParts.length === 0);
if (
reasoningContent.length === 0
&& (toolCalls.length > 0 || thinkingParts.length > 0)
&& (toolCalls.length > 0 || thinkingParts.length > 0 || reflectionPlaceholder)
&& wireSerializesThinking
) {
const cached = toolCalls
Expand Down
14 changes: 6 additions & 8 deletions src/adapters/openai-chat/passthrough.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,15 +170,13 @@ export function buildOpenAIChatPassthroughRequest(
// The binding constraint is the final size base + net, so a shrinking replay may legitimately
// measure a base above the cap; only a base above max - net makes the result unserializable.
let baseBytes = 0;
if (replayPlan.hasChanges) {
try {
baseBytes = jsonUtf8Bytes(transformedBody(), maxSerializedBytes - replayPlan.serializedBytes);
} catch (error) {
if (isTranslatorBudgetExceededError(error)) {
throw new TranslatorBudgetExceededError("request_copies", maxSerializedBytes);
}
throw error;
try {
baseBytes = jsonUtf8Bytes(transformedBody(), maxSerializedBytes - replayPlan.serializedBytes);
} catch (error) {
if (isTranslatorBudgetExceededError(error)) {
throw new TranslatorBudgetExceededError("request_copies", maxSerializedBytes);
}
throw error;
}
if (replayPlan.serializedBytes > maxSerializedBytes - baseBytes) {
throw new TranslatorBudgetExceededError("request_copies", maxSerializedBytes);
Expand Down
43 changes: 27 additions & 16 deletions src/adapters/openai-chat/reasoning-replay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,23 @@ export const isChatRecord = (value: unknown): value is Rec =>

const REASONING_CONTENT_FIELD_BYTES = Buffer.byteLength('"reasoning_content":', "utf8");

const isDeepSeekThinkingModel = (modelId: string): boolean =>
export const isDeepSeekThinkingModel = (modelId: string): boolean =>
/(?:^|\/)deepseek-(?:v\d+(?:\.\d+)*-(?:flash|pro)|flash|pro)(?:$|[-_:])/i.test(modelId);

export const isReflectionThinkingModel = (modelId: string): boolean =>
/(?:^|\/)(?:reflection|stealth)(?:$|[-_:/.])/i.test(modelId);

export const isThinkingModelFamily = (modelId: string): boolean =>
isDeepSeekThinkingModel(modelId) || isReflectionThinkingModel(modelId);

/** DeepSeek thinking tool replay is a model contract, independent of the gateway's name. */
export function chatReasoningReplayPolicy(provider: OcxProviderConfig, modelId: string): {
preserve: boolean; placeholder: boolean;
} {
// Match whole model-family tokens, including dotted versions, namespace and deployment suffixes.
// Legacy chat/reasoner and distilled R1 models do not share the thinking tool-call contract.
const preserve = provider.preserveReasoningContentModels === undefined
? isDeepSeekThinkingModel(modelId)
? isThinkingModelFamily(modelId)
: modelInList(provider.preserveReasoningContentModels, modelId);
return {
preserve,
Expand Down Expand Up @@ -59,21 +65,26 @@ export function planNativeChatReasoningReplay(
const plans: ReplayMessagePlan[] = [];
let serializedBytes = 0;
for (const message of messages) {
if (!isChatRecord(message) || message.role !== "assistant"
|| (typeof message.reasoning_content === "string" && message.reasoning_content.length > 0)) continue;
const calls = Array.isArray(message.tool_calls) ? message.tool_calls : [];
// DeepSeek's tools contract covers all prior assistant turns, even plain answers.
// Those turns have no call id for original replay; only the configured fallback applies.
if (calls.length === 0 && !(hasTools && isDeepSeekThinkingModel(modelId))) continue;
const cached = new Set<string>();
for (const tool of calls) {
if (!isChatRecord(tool) || typeof tool.id !== "string") continue;
const text = peekReasoningForCall(tool.id, scope);
if (text) cached.add(text);
if (!isChatRecord(message) || message.role !== "assistant") continue;
if (typeof message.reasoning_content === "string" && message.reasoning_content.length > 0) continue;
let parts: string[];
if (typeof message.reasoning === "string" && message.reasoning.length > 0) {
parts = [message.reasoning];
} else {
const calls = Array.isArray(message.tool_calls) ? message.tool_calls : [];
// DeepSeek tools contract covers prior assistant turns when tools are present.
// Thinking models like reflection/beam require reasoning replay even without tools.
if (calls.length === 0 && !(hasTools && isDeepSeekThinkingModel(modelId)) && !isReflectionThinkingModel(modelId)) continue;
const cached = new Set<string>();
for (const tool of calls) {
if (!isChatRecord(tool) || typeof tool.id !== "string") continue;
const text = peekReasoningForCall(tool.id, scope);
if (text) cached.add(text);
}
parts = cached.size > 0
? [...cached]
: policy.placeholder ? [" "] : [];
}
const parts = cached.size > 0
? [...cached]
: policy.placeholder ? [" "] : [];
if (parts.length === 0) continue;

// The envelope delta around the joined value is exact for the plain JSON the Chat
Expand Down
23 changes: 22 additions & 1 deletion src/combos/jev-decision-contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,27 @@ export function isSystemOneEndpoint(baseUrl: string): boolean {
*/
export function jevDecisionEndpointUrl(baseUrl: string): string {
const raw = baseUrl.trim();
const stripped = raw.replace(/\/+$/, "");
let stripped = raw.replace(/\/+$/, "");
try {
const parsed = new URL(raw);
if (parsed.hostname === "ai-gateway.vercel.sh") {
if (!stripped.endsWith("/decisions")) {
stripped = stripped.replace(/\/systemone$/, "").replace(/\/+$/, "") + "/decisions";
}
return stripped;
}
} catch {
return stripped.endsWith("/systemone") ? stripped : raw;
}
return stripped.endsWith("/systemone") ? stripped : raw;
}

/** Check if endpoint path targets /decisions or Vercel AI Gateway */
export function isDecisionsEndpoint(url: string): boolean {
try {
const parsed = new URL(url.trim());
return parsed.hostname === "ai-gateway.vercel.sh";
} catch {
return false;
}
}
4 changes: 3 additions & 1 deletion src/combos/jev-service-exchange.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { providerMatchesRegistryTransport } from "../providers/registry";
import type { OcxConfig, OcxProviderConfig } from "../types";
import type { JevDecision, ResolveJevDecisionOptions } from "./jev";
import {
isDecisionsEndpoint,
isSystemOneEndpoint,
jevDecisionEndpointUrl,
JEV_DECISION_TIMEOUT_DEFAULT_MS,
Expand Down Expand Up @@ -48,6 +49,7 @@ export type JevServiceExchangeOptions = Pick<ResolveJevDecisionOptions,
export interface JevDecisionEndpointShape {
model: string;
descriptiveCriteria: boolean;
url?: string;
}

/** The decision deadline: the configured value when in bounds, otherwise the four-second default. */
Expand Down Expand Up @@ -208,7 +210,7 @@ export async function exchangeJevDecision<T>(
if (endpoint === null) return { gate: "invalid" };
if (!endpoint) return { gate: "missing_key" };

const request = prepareRequestBody(prepare, { model: endpoint.model, descriptiveCriteria: endpoint.descriptiveCriteria });
const request = prepareRequestBody(prepare, { model: endpoint.model, descriptiveCriteria: endpoint.descriptiveCriteria, ...(isDecisionsEndpoint(endpoint.url) ? { url: endpoint.url } : {}) });
if (options.signal?.aborted) throw options.signal.reason;
if ("gate" in request) return request;
const requestBody = request.body;
Expand Down
104 changes: 95 additions & 9 deletions src/combos/jev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
JEV_MAX_CANDIDATE_FIELD_CHARS,
} from "./types";

import { isDecisionsEndpoint } from "./jev-decision-contract";
import {
exchangeJevDecision,
JEV_PROVIDER_ID,
Expand All @@ -25,6 +26,8 @@ export const JEV_MAX_CANDIDATES = 64;
/** Ollama's System One choice questions accept 2..26 options; other services share the floor. */
const SELF_HOSTED_MIN_OPTIONS = 2;
const SELF_HOSTED_MAX_OPTIONS = 26;
const DECISIONS_MIN_OPTIONS = 2;
const DECISIONS_MAX_OPTIONS = 255;

const TASK_CHARS = 500;
const TASK_HEAD_CHARS = 320;
Expand Down Expand Up @@ -570,19 +573,46 @@ export function parseJevDecision(
payload: unknown,
candidates: readonly JevCandidate[],
): Pick<JevDecision, "targetKey" | "effort" | "confidence" | "chosenProbability" | "usage"> {
if (!isRecord(payload) || !isRecord(payload.answers) || !isRecord(payload.answers.route)) {
if (!isRecord(payload)) {
throw new Error("missing JEV route decision");
}
let answer: Record<string, unknown> | undefined;
if (isRecord(payload.answers) && isRecord(payload.answers.route)) {
answer = payload.answers.route as Record<string, unknown>;
} else if (Array.isArray(payload.answers)) {
const routeAnswers = payload.answers.filter((a): a is Record<string, unknown> => isRecord(a) && a.name === "route");
if (routeAnswers.length === 1) {
answer = routeAnswers[0];
} else if (routeAnswers.length > 1) {
throw new Error("duplicate JEV route decision");
}
}
if (!answer) {
throw new Error("missing JEV route decision");
}
const answer = payload.answers.route;
const options = candidateOptions(candidates);
if (typeof answer.choice !== "string" || !options.has(answer.choice)) {
const choiceValue = typeof answer.choice === "string" ? answer.choice : (typeof answer.value === "string" ? answer.value : undefined);
if (!choiceValue || !options.has(choiceValue)) {
throw new Error("unknown JEV route choice");
}

let chosenProbability: number | undefined;
if (answer.probabilities !== undefined) {
const probabilities = answer.probabilities;
if (!isRecord(probabilities)) throw new Error("invalid JEV route probabilities");
let probabilities: Record<string, unknown>;
if (Array.isArray(answer.probabilities)) {
probabilities = {};
for (const item of answer.probabilities) {
if (!isRecord(item)) throw new Error("invalid JEV route probabilities");
const k = typeof item.choice === "string" ? item.choice : (typeof item.value === "string" ? item.value : undefined);
if (!k) throw new Error("invalid JEV route probabilities");
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
if (Object.hasOwn(probabilities, k)) throw new Error("invalid JEV route probabilities");
probabilities[k] = item.probability;
}
} else if (isRecord(answer.probabilities)) {
probabilities = answer.probabilities as Record<string, unknown>;
} else {
throw new Error("invalid JEV route probabilities");
}
const expected = [...options.keys()].sort();
const actual = Object.keys(probabilities).sort();
if (expected.length !== actual.length || expected.some((key, index) => key !== actual[index])) {
Expand All @@ -593,15 +623,15 @@ export function parseJevDecision(
throw new Error("invalid JEV route probabilities");
}
const numeric = values as number[];
const selected = probabilities[answer.choice] as number;
const selected = probabilities[choiceValue] as number;
if (Math.abs(numeric.reduce((sum, value) => sum + value, 0) - 1) > 0.02
|| selected < Math.max(...numeric) - 1e-6) {
throw new Error("inconsistent JEV route distribution");
}
chosenProbability = selected;
}

const option = options.get(answer.choice)!;
const option = options.get(choiceValue)!;
const confidence = typeof answer.confidence === "number"
&& Number.isFinite(answer.confidence)
&& answer.confidence >= 0
Expand Down Expand Up @@ -634,6 +664,31 @@ export function fallbackDecision(
* Every operational or response failure returns the supplied first-eligible fallback. A caller
* abort is the exception: request cancellation remains cancellation and is rethrown by identity.
*/
function formatDecisionsInput(state: Record<string, unknown>): string {
const parts: string[] = [];
if (typeof state.task === "string" && state.task.trim()) {
parts.push(`Task: ${state.task.trim()}`);
}
if (isRecord(state.signals)) {
const sigs: string[] = [];
if (state.signals.has_image === true) sigs.push("has_image");
if (state.signals.tool_history === true) sigs.push("tool_history");
if (sigs.length > 0) parts.push(`Signals: ${sigs.join(", ")}`);
}
if (typeof state.previous_assistant === "string" && state.previous_assistant.trim()) {
parts.push(`Recent Assistant: ${state.previous_assistant.trim()}`);
}
if (isRecord(state.step) && state.step.type === "tool_step") {
const step = state.step;
const toolName = isRecord(step.tool_call) && typeof step.tool_call.name === "string" ? step.tool_call.name : "";
const toolOutput = typeof step.last_tool_output_tail === "string" ? step.last_tool_output_tail : "";
if (toolName || toolOutput) {
parts.push(`Recent Tool Call: ${toolName}\nTool Output: ${toolOutput}`.trim());
}
}
return parts.length > 0 ? parts.join("\n\n") : JSON.stringify(state);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
}

export async function resolveJevDecision(options: ResolveJevDecisionOptions): Promise<JevDecision> {
const now = options.now ?? Date.now;
const startedAt = now();
Expand All @@ -647,15 +702,46 @@ export async function resolveJevDecision(options: ResolveJevDecisionOptions): Pr

let candidates = options.candidates;
const exchanged = await exchangeJevDecision(options, (endpoint) => {
if (endpoint.descriptiveCriteria) {
const isDecisions = Boolean(endpoint.url && isDecisionsEndpoint(endpoint.url));
const optionCount = candidateOptions(options.candidates).size;
if (isDecisions) {
if (optionCount < DECISIONS_MIN_OPTIONS) return "no_choices";
if (optionCount > DECISIONS_MAX_OPTIONS) return "invalid";
} else if (endpoint.descriptiveCriteria) {
// Self-hosted choice questions accept 2..26 options; decide locally instead of spending a
// round-trip on a request the service will refuse.
const optionCount = candidateOptions(options.candidates).size;
if (optionCount < SELF_HOSTED_MIN_OPTIONS) return "no_choices";
if (optionCount > SELF_HOSTED_MAX_OPTIONS) return "invalid";
}
const state = buildJevState(options.body, options.candidates);
if (!hasJevDecisionState(state)) return "no_state";
if (isDecisions && endpoint.url) {
const payload = boundedJevQuotaPayload(jevQuotaCandidates(options), rows => {
const routeOptions = candidateOptions(rows);
return JSON.stringify({
model: endpoint.model,
input: formatDecisionsInput(state),
questions: [
{
name: "route",
type: "choice",
instructions: "Which target AND reasoning effort together best fit the next model call? Select sufficient capability and reasoning for a correct next step while avoiding unnecessary resource use.",
choices: [...routeOptions.entries()].map(([choice, opt]) => {
const cand = rows.find(r => r.key === opt.targetKey);
const desc = criterionDescription(opt.criterion, cand?.quota);
const note = cand?.modelProfile?.trim();
return {
value: choice,
description: note ? `${desc} (${note})` : desc,
};
}),
},
],
});
}, REQUEST_LIMIT);
candidates = payload.candidates;
return { body: payload.body };
}
const payload = boundedJevQuotaPayload(jevQuotaCandidates(options), rows => JSON.stringify({
model: endpoint.model, state,
questions: buildJevRouteQuestion(rows, { descriptiveCriteria: endpoint.descriptiveCriteria }),
Expand Down
Loading
Loading