Skip to content
3 changes: 3 additions & 0 deletions docs/src/content/docs/docs/AIAssistant.md
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,9 @@ imports new models and refreshed context limits from the provider's model source
once a day and whenever provider settings open, so model lists stay current
without plugin updates. Auto-sync only adds models and updates metadata - it
never removes models you have configured. Use **Sync now** to refresh on demand.
Models that arrive while you are editing a provider appear in its list right
away, and the **Sync now** notice counts every model added to the list you were
looking at when you clicked it.

Auto-sync is on by default for the built-in OpenAI and Gemini providers and for
providers added from a card. It does nothing while **Disable AI & online
Expand Down
5 changes: 5 additions & 0 deletions docs/src/content/docs/docs/QuickAddAPI.md
Original file line number Diff line number Diff line change
Expand Up @@ -792,6 +792,11 @@ outright with a provider error - use a current model rather than expecting a bes
These accept only the default `temperature` (omit it from `modelOptions`), and QuickAdd
automatically sends `maxOutputTokens` as `max_completion_tokens` for them. The agent's default
path sets neither, so `quickAddApi.ai.agent({ model: "gpt-5" })` works as-is.

GPT-5.6 and GPT-6 models reason by default, and OpenAI's Chat Completions API rejects function
tools for them unless reasoning is off. When a tool turn is rejected for that reason, QuickAdd
retries it once with `reasoning_effort: "none"`. If you set `reasoning_effort` yourself in
`modelOptions`, QuickAdd keeps it and shows the provider's error instead.
:::

### `getModels(): string[]`
Expand Down
166 changes: 166 additions & 0 deletions src/ai/OpenAIRequest.toolReasoning.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
import { beforeEach, describe, expect, it } from "vitest";
import type { AIProvider, Model } from "./Provider";
import type { NormalizedChatRequest } from "./tools/NormalizedTools";

import { storeState, mocks, makeApp } from "../../tests/helpers/ai/requestHarness";

const { requestUrlMock, noticeMock } = mocks;

const { chatRequest } = await import("./OpenAIRequest");

const openaiProvider: AIProvider = {
name: "OpenAI",
endpoint: "https://api.openai.com/v1",
kind: "openai",
apiKey: "sk",
models: [],
modelSource: "modelsDev",
};

const gpt6: Model = {
name: "gpt-6-sol",
maxTokens: 1_050_000,
maxOutputTokens: 128_000,
supportsTemperature: false,
};

// Exact live error for gpt-6-sol with function tools on /v1/chat/completions
// (2026-09-26); gpt-5.6-* and the other gpt-6-* models return the same text.
function toolsWhileReasoningFailure(model = "gpt-6-sol") {
return {
status: 400,
json: {
error: {
message: `Function tools with reasoning_effort are not supported for ${model} in /v1/chat/completions. To use function tools, use /v1/responses or set reasoning_effort to 'none'.`,
type: "invalid_request_error",
param: null,
code: null,
},
},
};
}

function toolCallSuccess() {
return {
status: 200,
json: Promise.resolve({
id: "1",
model: "gpt-6-sol",
choices: [
{
finish_reason: "tool_calls",
index: 0,
message: {
role: "assistant",
content: null,
tool_calls: [
{
id: "call_1",
type: "function",
function: { name: "get_weather", arguments: '{"city":"Paris"}' },
},
],
},
},
],
usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
created: 0,
}),
};
}

function toolRequest(
modelParams: Record<string, unknown> = {},
): NormalizedChatRequest {
return {
messages: [{ role: "user", content: "Weather in Paris?" }],
modelParams,
tools: [
{
name: "get_weather",
description: "Get weather",
parameters: {
type: "object",
properties: { city: { type: "string" } },
required: ["city"],
},
},
],
toolChoice: "auto",
};
}

function sentBody(callIndex: number): Record<string, unknown> {
return JSON.parse(requestUrlMock.mock.calls[callIndex][0].body as string);
}

beforeEach(() => {
requestUrlMock.mockReset();
noticeMock.mockReset();
storeState.disableOnlineFeatures = false;
});

describe("function tools on models that reason by default", () => {
it("retries once with reasoning_effort 'none' when the model rejects tools while reasoning", async () => {
requestUrlMock
.mockReturnValueOnce(Promise.resolve(toolsWhileReasoningFailure()))
.mockReturnValueOnce(Promise.resolve(toolCallSuccess()));

const res = await chatRequest(makeApp(), "sk", gpt6, openaiProvider, toolRequest());

expect(res.toolCalls?.map((call) => call.name)).toEqual(["get_weather"]);
expect(requestUrlMock).toHaveBeenCalledTimes(2);
expect(sentBody(0).reasoning_effort).toBeUndefined();
expect(sentBody(1).reasoning_effort).toBe("none");
expect(sentBody(1).tools).toEqual(sentBody(0).tools);
});

it("keeps a reasoning effort the caller chose and surfaces the error", async () => {
requestUrlMock.mockReturnValueOnce(
Promise.resolve(toolsWhileReasoningFailure()),
);

await expect(
chatRequest(
makeApp(),
"sk",
gpt6,
openaiProvider,
toolRequest({ reasoning_effort: "high" }),
),
).rejects.toThrow(/reasoning_effort/);
expect(requestUrlMock).toHaveBeenCalledTimes(1);
});

it("does not retry other 400s", async () => {
requestUrlMock.mockReturnValueOnce(
Promise.resolve({
status: 400,
json: {
error: {
message: "Invalid schema for function 'get_weather'.",
type: "invalid_request_error",
},
},
}),
);

await expect(
chatRequest(makeApp(), "sk", gpt6, openaiProvider, toolRequest()),
).rejects.toThrow(/Invalid schema/);
expect(requestUrlMock).toHaveBeenCalledTimes(1);
});

it("does not add reasoning_effort to a request without tools", async () => {
requestUrlMock.mockReturnValueOnce(
Promise.resolve(toolsWhileReasoningFailure()),
);

await expect(
chatRequest(makeApp(), "sk", gpt6, openaiProvider, {
messages: [{ role: "user", content: "hi" }],
}),
).rejects.toThrow();
expect(requestUrlMock).toHaveBeenCalledTimes(1);
});
});
42 changes: 41 additions & 1 deletion src/ai/OpenAIRequest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -206,10 +206,26 @@ export async function chatRequest(
});

try {
const dispatch = (body: Record<string, unknown>) => dispatchProviderRequest<Record<string, unknown>>({
const send = (body: Record<string, unknown>) => dispatchProviderRequest<Record<string, unknown>>({
kind, apiKey, provider: modelProvider, model, body,
afterRequest: afterRequestCallback,
});
const dispatch = async (body: Record<string, unknown>) => {
try {
return await send(body);
} catch (error) {
const retryBody = toolReasoningRetryBody(
kind,
body,
(error as { message?: string }).message ?? String(error),
);
if (!retryBody) throw error;
log.logMessage(
`[AI Chat ${requestLogId}] ${model.name} rejected function tools while reasoning; retrying with reasoning_effort "none".`,
);
return send(retryBody);
}
};
const json = await retrySampling(
() => dispatch(body),
() => dispatch(buildChatBody(kind, model.name, {
Expand Down Expand Up @@ -260,6 +276,30 @@ export async function chatRequest(
}
}

// "Function tools with reasoning_effort are not supported for gpt-6-sol in
// /v1/chat/completions. To use function tools, use /v1/responses or set
// reasoning_effort to 'none'." (verified live 2026-09-26 for the gpt-6 and
// gpt-5.6 families, which reason by default; gpt-5.5 and older default to none).
const TOOLS_NEED_NO_REASONING_RE =
/function tools with reasoning_effort are not supported[\s\S]*reasoning_effort to 'none'/i;

/**
* The body to retry a Chat Completions tool request with when the model
* rejected function tools because it reasons by default, or null when the
* error is anything else or the caller already chose a reasoning effort.
*/
export function toolReasoningRetryBody(
kind: ReturnType<typeof getProviderKind>,
body: Record<string, unknown>,
errorText: string,
): Record<string, unknown> | null {
if (kind !== "openai") return null;
if (!Array.isArray(body.tools) || body.tools.length === 0) return null;
if (body.reasoning_effort !== undefined) return null;
if (!TOOLS_NEED_NO_REASONING_RE.test(errorText)) return null;
return { ...body, reasoning_effort: "none" };
}

async function retrySampling<T>(attempt: () => Promise<T>, retry: () => Promise<T>, context: {
sentKeys: ReturnType<typeof sentSamplingParams>;
model: Model;
Expand Down
48 changes: 48 additions & 0 deletions src/ai/Provider.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { describe, it, expect } from "vitest";
import type { AIProvider } from "./Provider";
import {
CURRENT_MODEL_SEEDS,
DefaultProviders,
activeModelRef,
ensureProviderIds,
getProviderKind,
Expand Down Expand Up @@ -119,3 +121,49 @@ describe("activeModelRef", () => {
expect(activeModelRef("gpt-4o", undefined)).toBeUndefined();
});
});

describe("shipped model seeds", () => {
// Values verified live on 2026-09-26: listed by /v1/models, a completion
// succeeds, and temperature: 0.5 is rejected (400 unsupported_value).
it("offer the GPT-6 generation on a fresh install, before any sync", () => {
const openai = DefaultProviders.find((p) => p.id === "openai");
const byName = new Map(openai?.models.map((m) => [m.name, m]));
for (const name of [
"gpt-6-sol",
"gpt-6-luna",
"gpt-6-astra",
"gpt-5.6-sol",
"gpt-5.6-luna",
"gpt-5.6-terra",
]) {
expect(byName.get(name), name).toEqual({
name,
maxTokens: 1_050_000,
maxOutputTokens: 128_000,
supportsTemperature: false,
});
}
});

it("let the gpt-5.4 family keep a user's temperature (accepted live)", () => {
for (const name of ["gpt-5.4", "gpt-5.4-mini", "gpt-5.4-nano"]) {
const seed = CURRENT_MODEL_SEEDS.openai.find((m) => m.name === name);
expect(seed?.supportsTemperature, name).toBe(true);
}
const gpt55 = CURRENT_MODEL_SEEDS.openai.find((m) => m.name === "gpt-5.5");
expect(gpt55?.supportsTemperature).toBe(false);
});

it("no longer seed gemini-3-pro-preview, which Google shut down", () => {
const names = CURRENT_MODEL_SEEDS.google.map((m) => m.name);
expect(names).not.toContain("gemini-3-pro-preview");
expect(names).toContain("gemini-3.8-flash");
});

it("list each model once per provider", () => {
for (const [key, seeds] of Object.entries(CURRENT_MODEL_SEEDS)) {
const names = seeds.map((m) => m.name);
expect(new Set(names).size, key).toBe(names.length);
}
});
});
32 changes: 26 additions & 6 deletions src/ai/Provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,18 +185,31 @@ export interface Model {
* offline fallback: live discovery (models.dev / the provider's models endpoint)
* is the source of truth, and auto-sync keeps lists current without plugin
* releases. Each entry below was verified live (directory metadata + a real
* completion) on 2026-07-07. When touching this table, re-verify against
* https://models.dev/api.json and the provider APIs — never add ids from memory.
* completion) on 2026-07-07. Refreshed 2026-09-26: the OpenAI list was
* re-verified live (listed by /v1/models, a real completion, and a completion
* with temperature to confirm supportsTemperature); the Google and Anthropic
* additions come from models.dev metadata cross-checked against the vendors'
* model/deprecation docs, without a live completion (no key was available).
* When touching this table, re-verify against https://models.dev/api.json and
* the provider APIs — never add ids from memory.
*/
export const CURRENT_MODEL_SEEDS: Record<
"openai" | "google" | "anthropic",
Model[]
> = {
openai: [
{ name: "gpt-6-sol", maxTokens: 1_050_000, maxOutputTokens: 128_000, supportsTemperature: false },
{ name: "gpt-6-luna", maxTokens: 1_050_000, maxOutputTokens: 128_000, supportsTemperature: false },
{ name: "gpt-6-astra", maxTokens: 1_050_000, maxOutputTokens: 128_000, supportsTemperature: false },
Comment thread
coderabbitai[bot] marked this conversation as resolved.
{ name: "gpt-5.6-sol", maxTokens: 1_050_000, maxOutputTokens: 128_000, supportsTemperature: false },
{ name: "gpt-5.6-luna", maxTokens: 1_050_000, maxOutputTokens: 128_000, supportsTemperature: false },
{ name: "gpt-5.6-terra", maxTokens: 1_050_000, maxOutputTokens: 128_000, supportsTemperature: false },
{ name: "gpt-5.5", maxTokens: 1_050_000, maxOutputTokens: 128_000, supportsTemperature: false },
{ name: "gpt-5.4", maxTokens: 1_050_000, maxOutputTokens: 128_000, supportsTemperature: false },
{ name: "gpt-5.4-mini", maxTokens: 400_000, maxOutputTokens: 128_000, supportsTemperature: false },
{ name: "gpt-5.4-nano", maxTokens: 400_000, maxOutputTokens: 128_000, supportsTemperature: false },
// The gpt-5.4 family accepts temperature (live 200 with temperature: 0.5,
// matching models.dev); only gpt-5.5 and newer reject it.
{ name: "gpt-5.4", maxTokens: 1_050_000, maxOutputTokens: 128_000, supportsTemperature: true },
{ name: "gpt-5.4-mini", maxTokens: 400_000, maxOutputTokens: 128_000, supportsTemperature: true },
{ name: "gpt-5.4-nano", maxTokens: 400_000, maxOutputTokens: 128_000, supportsTemperature: true },
{ name: "gpt-4.1", maxTokens: 1_047_576, maxOutputTokens: 32_768, supportsTemperature: true },
{ name: "gpt-4.1-mini", maxTokens: 1_047_576, maxOutputTokens: 32_768, supportsTemperature: true },
{ name: "gpt-4o", maxTokens: 128_000, maxOutputTokens: 16_384, supportsTemperature: true },
Expand All @@ -205,16 +218,23 @@ export const CURRENT_MODEL_SEEDS: Record<
{ name: "o4-mini", maxTokens: 200_000, maxOutputTokens: 100_000, supportsTemperature: false },
],
google: [
{ name: "gemini-3.8-flash", maxTokens: 1_048_576, maxOutputTokens: 65_536, supportsTemperature: true },
{ name: "gemini-3.7-flash", maxTokens: 1_048_576, maxOutputTokens: 65_536, supportsTemperature: true },
{ name: "gemini-3.6-flash", maxTokens: 1_048_576, maxOutputTokens: 65_536, supportsTemperature: true },
{ name: "gemini-3.5-flash", maxTokens: 1_048_576, maxOutputTokens: 65_536, supportsTemperature: true },
{ name: "gemini-3.5-flash-lite", maxTokens: 1_048_576, maxOutputTokens: 65_536, supportsTemperature: true },
{ name: "gemini-3.1-pro-preview", maxTokens: 1_048_576, maxOutputTokens: 65_536, supportsTemperature: true },
{ name: "gemini-3.1-flash-lite", maxTokens: 1_048_576, maxOutputTokens: 65_536, supportsTemperature: true },
{ name: "gemini-3-pro-preview", maxTokens: 1_048_576, maxOutputTokens: 65_536, supportsTemperature: true },
// gemini-3-pro-preview was shut down 2026-03-09 (the id now aliases
// gemini-3.1-pro-preview), so it is no longer seeded.
{ name: "gemini-3-flash-preview", maxTokens: 1_048_576, maxOutputTokens: 65_536, supportsTemperature: true },
{ name: "gemini-2.5-pro", maxTokens: 1_048_576, maxOutputTokens: 65_536, supportsTemperature: true },
{ name: "gemini-2.5-flash", maxTokens: 1_048_576, maxOutputTokens: 65_536, supportsTemperature: true },
{ name: "gemini-2.5-flash-lite", maxTokens: 1_048_576, maxOutputTokens: 65_536, supportsTemperature: true },
],
anthropic: [
{ name: "claude-opus-5-5", maxTokens: 1_000_000, maxOutputTokens: 128_000, supportsTemperature: false },
{ name: "claude-fable-5-1", maxTokens: 1_000_000, maxOutputTokens: 128_000, supportsTemperature: false },
Comment thread
chhoumann marked this conversation as resolved.
{ name: "claude-fable-5", maxTokens: 1_000_000, maxOutputTokens: 128_000, supportsTemperature: false },
{ name: "claude-sonnet-5", maxTokens: 1_000_000, maxOutputTokens: 128_000, supportsTemperature: false },
{ name: "claude-opus-4-8", maxTokens: 1_000_000, maxOutputTokens: 128_000, supportsTemperature: false },
Expand Down
Loading
Loading