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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
### Added

- The assistant starts on the model you used last
- Advanced settings for OpenAI and OpenAI compatible providers: endpoint (Auto, Responses API or Chat Completions), reasoning effort, reasoning summary, verbosity, max output tokens and temperature. Left on Default, nothing extra is sent

### Changed

Expand Down
29 changes: 28 additions & 1 deletion locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -218,7 +218,34 @@
"contextSourceDefault": "default for unknown models",
"contextLoadFailed": "Could not read the context windows",
"contextRefresh": "Refresh",
"contextWindowInvalid": "Enter the context window as a whole number of tokens, at least 1024."
"contextWindowInvalid": "Enter the context window as a whole number of tokens, at least 1024.",
"advanced": "Advanced",
"advancedHint": "Leave a setting on Default to use the model's own.",
"advancedDefault": "Default",
"advancedDefaultAuto": "Default (auto)",
"advancedAuto": "Auto",
"advancedEndpoint": "Endpoint",
"advancedEndpointResponses": "Responses API",
"advancedEndpointChat": "Chat Completions",
"advancedReasoningEffort": "Reasoning effort",
"advancedReasoningSummary": "Reasoning summary",
"advancedReasoningSummaryHint": "Responses API only.",
"advancedVerbosity": "Verbosity",
"advancedNone": "None",
"advancedMinimal": "Minimal",
"advancedLow": "Low",
"advancedMedium": "Medium",
"advancedHigh": "High",
"advancedXhigh": "Extra high",
"advancedMax": "Max",
"advancedConcise": "Concise",
"advancedDetailed": "Detailed",
"advancedOff": "Off",
"advancedMaxOutputTokens": "Max output tokens",
"advancedMaxOutputTokensInvalid": "Enter a whole number from 1 to 1,000,000.",
"advancedTemperature": "Temperature",
"advancedTemperatureHint": "OpenAI reasoning models accept this only with reasoning effort None, so with any other effort it is not sent.",
"advancedTemperatureInvalid": "Enter a number from 0 to 2."
},
"nav": {
"ai": "AI assistant"
Expand Down
4 changes: 4 additions & 0 deletions migrations/mysql/0013_provider_advanced.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
-- ai 0013: provider_advanced
-- Generated by termix-plugin migrations. Review before committing.

ALTER TABLE `p_ai_providers` ADD COLUMN `advanced` longtext;
4 changes: 4 additions & 0 deletions migrations/postgres/0013_provider_advanced.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
-- ai 0013: provider_advanced
-- Generated by termix-plugin migrations. Review before committing.

ALTER TABLE "p_ai_providers" ADD COLUMN "advanced" text;
3 changes: 3 additions & 0 deletions migrations/snapshot.json
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,9 @@
"modelOverrides": {
"type": "text"
},
"advanced": {
"type": "text"
},
"enabled": {
"type": "boolean",
"notNull": true,
Expand Down
4 changes: 4 additions & 0 deletions migrations/sqlite/0013_provider_advanced.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
-- ai 0013: provider_advanced
-- Generated by termix-plugin migrations. Review before committing.

ALTER TABLE "p_ai_providers" ADD COLUMN "advanced" text;
1 change: 1 addition & 0 deletions src/backend/ops/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,7 @@ export function registerOpsRoutes(
providerType: provider.providerType as ProviderConfig["providerType"],
baseUrl: provider.baseUrl,
apiKey: provider.apiKey,
advanced: provider.advanced,
fetch: createProviderFetch(
ctx.fetch,
await readPrivateAllowlist(ctx.settings),
Expand Down
87 changes: 75 additions & 12 deletions src/backend/providers/openai-responses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,24 @@ export function resetResponsesMemory(): void {
reasoningDisabled.clear();
}

/** The user's output limit, never above a caller's own cap. */
export function cappedMaxTokens(
limit: number | undefined,
request: Pick<ChatRequest, "maxTokens">,
): number | undefined {
if (limit === undefined) return request.maxTokens;
return request.maxTokens ? Math.min(limit, request.maxTokens) : limit;
}

export function usesResponsesApi(config: ProviderConfig): boolean {
if (
config.providerType !== "openai" &&
config.providerType !== "openai_compatible"
) {
return false;
}
if (config.advanced?.endpoint === "responses") return true;
if (config.advanced?.endpoint === "chat") return false;
if (config.providerType !== "openai") return false;
const base = config.baseUrl?.trim();
if (!base) return true;
Expand Down Expand Up @@ -76,17 +93,39 @@ export async function* streamResponses(
headers: Record<string, string>,
): AsyncIterable<ChatChunk> {
const memoryKey = `${url}|${request.model}`;
const advanced = config.advanced ?? {};
const reasoning: Record<string, string> = {};
if (advanced.reasoningEffort !== undefined) {
reasoning.effort = advanced.reasoningEffort;
}
const summary = advanced.reasoningSummary ?? "auto";
if (summary !== "off" && !reasoningDisabled.has(memoryKey)) {
reasoning.summary = summary;
}
const maxTokens = cappedMaxTokens(advanced.maxOutputTokens, request);
const skipTemperature =
advanced.temperature !== undefined &&
config.providerType === "openai" &&
advanced.reasoningEffort !== undefined &&
advanced.reasoningEffort !== "none";
if (skipTemperature) {
config.log?.warn(
`OpenAI skipped temperature for ${request.model} because reasoning effort is ${advanced.reasoningEffort}; temperature requires reasoning effort none.`,
);
}
let body: Record<string, unknown> = {
model: request.model,
instructions: request.system,
input: toResponsesInput(request),
stream: true,
store: false,
...(!reasoningDisabled.has(memoryKey)
? { reasoning: { summary: "auto" } }
...(Object.keys(reasoning).length ? { reasoning } : {}),
...(maxTokens !== undefined ? { max_output_tokens: maxTokens } : {}),
...(advanced.verbosity !== undefined
? { text: { verbosity: advanced.verbosity } }
: {}),
...(request.maxTokens !== undefined
? { max_output_tokens: request.maxTokens }
...(advanced.temperature !== undefined && !skipTemperature
? { temperature: advanced.temperature }
: {}),
...(request.tools.length
? {
Expand All @@ -109,27 +148,49 @@ export async function* streamResponses(
});

let response = await post(body);
if (response.status === 400) {
let temperatureDropped = false;
for (let attempt = 0; attempt < 2; attempt++) {
if (response.status !== 400 && response.status !== 422) break;
const detail = await response.text().catch(() => "");
if (/reasoning|summar|encrypted/i.test(detail) && body.reasoning) {
const reasoning = body.reasoning as Record<string, string> | undefined;
const temperatureError = /temperature/i.test(detail);
if (temperatureError && body.temperature !== undefined) {
config.log?.warn(
`OpenAI refused temperature for ${request.model}, continuing without it: ${detail.slice(0, 300)}`,
);
const { temperature: _temperature, ...rest } = body;
body = rest;
temperatureDropped = true;
} else if (
response.status === 400 &&
!(temperatureError && temperatureDropped) &&
// An effort the model refuses is the user's to fix, not a summary issue.
!(advanced.reasoningEffort !== undefined && /effort/i.test(detail)) &&
/reasoning|summar|encrypted/i.test(detail) &&
reasoning?.summary !== undefined
) {
config.log?.warn(
`OpenAI refused reasoning summaries for ${request.model}, continuing without them (no Thinking block): ${detail.slice(0, 300)}`,
);
reasoningDisabled.add(memoryKey);
const { reasoning: _dropped, ...rest } = body;
body = rest;
response = await post(body);
const { summary: _summary, ...remainingReasoning } = reasoning;
const { reasoning: _reasoning, ...rest } = body;
body = Object.keys(remainingReasoning).length
? { ...rest, reasoning: remainingReasoning }
: rest;
} else {
response = new Response(detail, {
status: response.status,
statusText: response.statusText,
});
break;
}
response = await post(body);
}
await assertOk(response, "OpenAI");

const calls: ToolCall[] = [];
const reasoning: unknown[] = [];
const reasoningBlocks: unknown[] = [];
let usage: { input_tokens?: number; output_tokens?: number } | null = null;
let stopReason: string | undefined;
let previousReasoningKey: string | undefined;
Expand Down Expand Up @@ -167,7 +228,7 @@ export async function* streamResponses(
break;
case "response.output_item.done": {
const item = frame.item;
if (item?.type === "reasoning") reasoning.push(item);
if (item?.type === "reasoning") reasoningBlocks.push(item);
if (item?.type !== "function_call" || !item.name) break;
let args: Record<string, unknown> = {};
try {
Expand Down Expand Up @@ -208,7 +269,9 @@ export async function* streamResponses(
}

for (const call of calls) yield { type: "tool_call", call };
if (reasoning.length) yield { type: "reasoning_state", blocks: reasoning };
if (reasoningBlocks.length) {
yield { type: "reasoning_state", blocks: reasoningBlocks };
}
if (usage) {
yield {
type: "usage",
Expand Down
46 changes: 41 additions & 5 deletions src/backend/providers/openai.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { assertOk, firstCount, joinUrl, readSseLines } from "./http.js";
import { streamResponses, usesResponsesApi } from "./openai-responses.js";
import {
cappedMaxTokens,
streamResponses,
usesResponsesApi,
} from "./openai-responses.js";
import { createThinkSplitter } from "./think-tags.js";
import type {
ChatChunk,
Expand Down Expand Up @@ -91,6 +95,22 @@ export const openAiAdapter: ProviderAdapter = {
return;
}

const advanced = config.advanced;
const maxTokens = cappedMaxTokens(advanced?.maxOutputTokens, request);
let temperature = advanced?.temperature;
// OpenAI's reasoning models take temperature only with effort none.
if (
temperature !== undefined &&
config.providerType === "openai" &&
advanced?.reasoningEffort !== undefined &&
advanced.reasoningEffort !== "none"
) {
config.log?.warn(
`OpenAI skipped temperature for ${request.model} because reasoning effort is ${advanced.reasoningEffort}; temperature requires reasoning effort none.`,
);
temperature = undefined;
}

let body: Record<string, unknown> = {
model: request.model,
messages: toOpenAiMessages(request),
Expand All @@ -100,11 +120,18 @@ export const openAiAdapter: ProviderAdapter = {
stream_options: { include_usage: true },
// OpenAI's own API refuses max_tokens for its reasoning models;
// compatible servers (vLLM, LM Studio, Groq, OpenRouter) know max_tokens.
...(request.maxTokens
...(maxTokens
? config.providerType === "openai"
? { max_completion_tokens: request.maxTokens }
: { max_tokens: request.maxTokens }
? { max_completion_tokens: maxTokens }
: { max_tokens: maxTokens }
: {}),
...(advanced?.reasoningEffort !== undefined
? { reasoning_effort: advanced.reasoningEffort }
: {}),
...(advanced?.verbosity !== undefined
? { verbosity: advanced.verbosity }
: {}),
...(temperature !== undefined ? { temperature } : {}),
...(request.tools.length
? {
tools: request.tools.map((tool) => ({
Expand All @@ -130,12 +157,21 @@ export const openAiAdapter: ProviderAdapter = {
// A server can refuse an option it does not know, or OpenAI's newer
// models refuse tools with their default reasoning effort. Ask again
// without the option, or with reasoning off.
for (let attempt = 0; attempt < 2; attempt++) {
for (let attempt = 0; attempt < 3; attempt++) {
if (response.status !== 400 && response.status !== 422) break;
const detail = await response.text().catch(() => "");
if (/stream_options/i.test(detail) && body.stream_options) {
const { stream_options: _dropped, ...rest } = body;
body = rest;
} else if (
/temperature/i.test(detail) &&
body.temperature !== undefined
) {
config.log?.warn(
`OpenAI refused temperature for ${request.model}, continuing without it: ${detail.slice(0, 300)}`,
);
const { temperature: _dropped, ...rest } = body;
body = rest;
} else if (
/reasoning_effort/i.test(detail) &&
/tools/i.test(detail) &&
Expand Down
14 changes: 14 additions & 0 deletions src/backend/providers/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,10 +95,24 @@ export type ProviderFetch = (
init: RequestInit,
) => Promise<Response>;

export type ReasoningEffort =
"none" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";

export interface ProviderAdvanced {
endpoint?: "auto" | "responses" | "chat";
reasoningEffort?: ReasoningEffort;
reasoningSummary?: "auto" | "concise" | "detailed" | "off";
verbosity?: "low" | "medium" | "high";
maxOutputTokens?: number;
temperature?: number;
}

export interface ProviderConfig {
providerType: AiProviderType;
baseUrl?: string | null;
apiKey?: string | null;
/** The user's API settings; absent fields keep the model's defaults. */
advanced?: ProviderAdvanced;
fetch: ProviderFetch;
/** Reports provider fallbacks to the server log. */
log?: { warn(message: string): void };
Expand Down
26 changes: 24 additions & 2 deletions src/backend/repository.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { and, desc, eq, gte, inArray } from "drizzle-orm";
import type { PluginContext } from "@termix-ssh/plugin-sdk/backend";
import type { ModelOverrides } from "./context-window.js";
import type { ProviderAdvanced } from "./providers/types.js";
import {
cliRuns as cliRunsDef,
conversations as conversationsDef,
Expand All @@ -27,6 +28,7 @@ export interface AiProviderRecord {
defaultModel: string | null;
/** The user's per-model settings, such as a context window override. */
modelOverrides: ModelOverrides;
advanced: ProviderAdvanced;
enabled: boolean;
createdAt: string;
updatedAt: string;
Expand Down Expand Up @@ -127,6 +129,7 @@ export interface AiProviderInput {
apiKey?: string | null;
defaultModel?: string | null;
modelOverrides?: ModelOverrides;
advanced?: ProviderAdvanced;
enabled?: boolean;
}

Expand Down Expand Up @@ -161,12 +164,28 @@ function parseOverrides(raw: unknown): ModelOverrides {
const storedOverrides = (overrides: ModelOverrides) =>
Object.keys(overrides).length ? JSON.stringify(overrides) : null;

/** The column's JSON as settings; anything unreadable is model defaults. */
function parseAdvanced(raw: unknown): ProviderAdvanced {
try {
const parsed = typeof raw === "string" ? JSON.parse(raw) : null;
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
? parsed
: {};
} catch {
return {};
}
}

const storedAdvanced = (advanced: ProviderAdvanced) =>
Object.keys(advanced).length ? JSON.stringify(advanced) : null;

/** A provider row as callers see it, without the retired window column. */
function toProvider(row: Record<string, unknown>): AiProviderRecord {
const { contextWindow: _retired, modelOverrides, ...rest } = row;
const { contextWindow: _retired, modelOverrides, advanced, ...rest } = row;
return {
...(rest as Omit<AiProviderRecord, "modelOverrides">),
...(rest as Omit<AiProviderRecord, "modelOverrides" | "advanced">),
modelOverrides: parseOverrides(modelOverrides),
advanced: parseAdvanced(advanced),
};
}

Expand Down Expand Up @@ -283,6 +302,7 @@ export async function createAiRepository(
apiKeyPrefix: apiKeyPrefix(input.apiKey),
defaultModel: input.defaultModel ?? null,
modelOverrides: storedOverrides(input.modelOverrides ?? {}),
advanced: storedAdvanced(input.advanced ?? {}),
enabled: input.enabled ?? true,
createdAt: stamp,
updatedAt: stamp,
Expand Down Expand Up @@ -313,6 +333,8 @@ export async function createAiRepository(
updates.defaultModel = input.defaultModel;
if (input.modelOverrides !== undefined)
updates.modelOverrides = storedOverrides(input.modelOverrides);
if (input.advanced !== undefined)
updates.advanced = storedAdvanced(input.advanced);
if (input.enabled !== undefined) updates.enabled = input.enabled;

// An empty string clears the key; undefined leaves it untouched.
Expand Down
Loading