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
147 changes: 120 additions & 27 deletions backend/src/lib/chat/modelCapabilities.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,52 +10,145 @@ import { discoverCompatibleModels } from "../llm/modelDiscovery";

test("GPT-5.6 is a ROSS-compatible main model", () => {
assert.equal(resolveModel("gpt-5.6", "fallback"), "gpt-5.6");
assert.equal(resolveModel("gpt-5.6-terra", "fallback"), "gpt-5.6-terra");
assert.equal(modelCapability("gpt-5.6")?.tier, "main");
});

test("reasoning effort is model-specific", () => {
assert.equal(supportsReasoningEffort("gpt-5.6", "max"), true);
assert.equal(supportsReasoningEffort("gpt-5.6-luna", "max"), true);
assert.equal(supportsReasoningEffort("gpt-5.5", "max"), false);
assert.equal(
supportsReasoningEffort("gemini-3-flash-preview", "high"),
false,
);
assert.equal(supportsReasoningEffort("gemini-3.6-flash", "minimal"), true);
assert.equal(supportsReasoningEffort("claude-opus-5", "max"), true);
assert.equal(supportsReasoningEffort("kimi-k3", "max"), true);
});

test("unsupported reasoning effort falls back to the model default", () => {
assert.equal(resolveReasoningEffort("gpt-5.5", "max"), "medium");
assert.equal(
resolveReasoningEffort("gemini-3-flash-preview", "high"),
undefined,
);
assert.equal(resolveReasoningEffort("gemini-2-flash", "high"), undefined);
});

test("key-scoped discovery exposes availability but never the API key", async () => {
test("key-scoped discovery exposes live compatible models for every provider", async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = async () =>
new Response(
JSON.stringify({
data: [{ id: "gpt-5.6" }, { id: "text-embedding-3-large" }],
}),
{ status: 200, headers: { "content-type": "application/json" } },
);
const calls: Array<{ url: string; headers: Headers }> = [];
globalThis.fetch = async (input, init) => {
const url = String(input);
calls.push({ url, headers: new Headers(init?.headers) });
const body = url.includes("api.openai.com")
? {
data: [
{ id: "gpt-5.6-sol" },
{ id: "gpt-5.6-terra" },
{ id: "gpt-5.6-luna" },
{ id: "gpt-6-preview" },
{ id: "text-embedding-3-large" },
],
}
: url.includes("api.anthropic.com")
? {
data: [{ id: "claude-opus-5", display_name: "Claude Opus 5" }],
has_more: false,
}
: url.includes("generativelanguage.googleapis.com")
? {
models: [
{
name: "models/gemini-3.6-flash",
baseModelId: "gemini-3.6-flash",
displayName: "Gemini 3.6 Flash",
supportedGenerationMethods: ["generateContent"],
},
{
name: "models/text-embedding-999",
supportedGenerationMethods: ["embedContent"],
},
],
}
: url.includes("api.x.ai")
? { data: [{ id: "grok-4.6" }, { id: "grok-image-1" }] }
: { data: [{ id: "kimi-k3" }, { id: "embedding-v1" }] };
return new Response(JSON.stringify(body), {
status: 200,
headers: { "content-type": "application/json" },
});
};
try {
const secret = "sk-test-secret-that-must-not-leak";
const result = await discoverCompatibleModels({ openai: secret });
const secrets = {
openai: "sk-openai-secret",
claude: "sk-claude-secret",
gemini: "gemini-secret",
xai: "xai-secret",
moonshot: "moonshot-secret",
};
const result = await discoverCompatibleModels(secrets);
for (const id of [
"gpt-5.6",
"gpt-5.6-sol",
"gpt-5.6-terra",
"gpt-5.6-luna",
"gpt-6-preview",
"claude-opus-5",
"gemini-3.6-flash",
"grok-4.6",
"kimi-k3",
]) {
assert.equal(
result.models.find((model) => model.id === id)?.available,
true,
id,
);
}
for (const id of [
"text-embedding-3-large",
"text-embedding-999",
"grok-image-1",
"embedding-v1",
]) {
assert.equal(
result.models.some((model) => model.id === id),
false,
id,
);
}
for (const secret of Object.values(secrets)) {
assert.equal(JSON.stringify(result).includes(secret), false);
}
assert.equal(calls.length, 5);
assert.equal(
result.models.find((model) => model.id === "gpt-5.6")?.available,
true,
calls
.find((call) => call.url.includes("api.anthropic.com"))
?.headers.get("x-api-key"),
secrets.claude,
);
assert.equal(
result.models.some((model) => model.id === "text-embedding-3-large"),
false,
calls
.find((call) => call.url.includes("generativelanguage.googleapis.com"))
?.headers.get("x-goog-api-key"),
secrets.gemini,
);
} finally {
globalThis.fetch = originalFetch;
}
});

test("successful discovery with no compatible chat model fails closed", async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = async () =>
new Response(JSON.stringify({ data: [{ id: "text-embedding-3-large" }] }), {
status: 200,
headers: { "content-type": "application/json" },
});
try {
const result = await discoverCompatibleModels({ openai: "sk-test" });
const openAIModels = result.models.filter(
(model) => model.provider === "openai",
);
assert.match(
result.models.find((model) => model.id === "gpt-5.5")
?.availabilityReason ?? "",
/does not currently list this model/i,
assert.ok(openAIModels.length > 0);
assert.equal(
openAIModels.every((model) => !model.available),
true,
);
assert.equal(JSON.stringify(result).includes(secret), false);
assert.match(openAIModels[0]?.availabilityReason ?? "", /does not list/i);
} finally {
globalThis.fetch = originalFetch;
}
Expand Down
17 changes: 9 additions & 8 deletions backend/src/lib/llm/claude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type {
} from "./types";
import { toClaudeTools } from "./tools";
import { createRawLlmStreamRecorder, logRawLlmStream } from "./rawStreamLog";
import { modelCapability } from "./models";

type ContentBlock =
| { type: "text"; text: string }
Expand Down Expand Up @@ -112,6 +113,7 @@ export async function streamClaude(
runTools,
apiKeys,
enableThinking,
reasoningEffort,
} = params;
const maxIter = params.maxIterations ?? 10;
const anthropic = client(apiKeys?.claude);
Expand All @@ -134,12 +136,11 @@ export async function streamClaude(
model,
system: systemPrompt,
messages: messages as Anthropic.MessageParam[],
tools: toolsEnabled && claudeTools.length
? (claudeTools as unknown as Tool[])
: undefined,
...(iter === 0 &&
toolsEnabled &&
params.requiredFirstToolName
tools:
toolsEnabled && claudeTools.length
? (claudeTools as unknown as Tool[])
: undefined,
...(iter === 0 && toolsEnabled && params.requiredFirstToolName
? {
tool_choice: {
type: "tool",
Expand All @@ -152,10 +153,10 @@ export async function streamClaude(
// Claude 4.x models require `thinking.type: "adaptive"` and
// drive effort via `output_config.effort` rather than a fixed
// token budget. We only opt in when the caller requested it.
...(enableThinking
...(enableThinking && modelCapability(model)?.reasoningEfforts.length
? ({
thinking: { type: "adaptive" },
output_config: { effort: "high" },
output_config: { effort: reasoningEffort ?? "high" },
} as unknown as Record<string, unknown>)
: {}),
// Extended thinking requires temperature to be default (omitted).
Expand Down
36 changes: 27 additions & 9 deletions backend/src/lib/llm/gemini.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type {
} from "./types";
import { toGeminiTools } from "./tools";
import { createRawLlmStreamRecorder, logRawLlmStream } from "./rawStreamLog";
import { modelCapability } from "./models";

type GeminiPart = {
text?: string;
Expand Down Expand Up @@ -168,6 +169,7 @@ export async function streamGemini(
runTools,
apiKeys,
enableThinking,
reasoningEffort,
} = params;
const maxIter = params.maxIterations ?? 10;
const ai = client(apiKeys?.gemini);
Expand All @@ -193,12 +195,11 @@ export async function streamGemini(
contents: contents as never,
config: {
systemInstruction: systemPrompt,
tools: toolsEnabled && functionDeclarations.length
? [{ functionDeclarations } as never]
: undefined,
...(iter === 0 &&
toolsEnabled &&
params.requiredFirstToolName
tools:
toolsEnabled && functionDeclarations.length
? [{ functionDeclarations } as never]
: undefined,
...(iter === 0 && toolsEnabled && params.requiredFirstToolName
? {
toolConfig: {
functionCallingConfig: {
Expand All @@ -212,9 +213,11 @@ export async function streamGemini(
// When disabled, explicitly zero the thinking budget so the
// model skips thinking entirely (saves tokens and latency
// for bulk extraction jobs).
thinkingConfig: enableThinking
? { includeThoughts: true }
: { thinkingBudget: 0 },
thinkingConfig: geminiThinkingConfig(
model,
!!enableThinking,
reasoningEffort,
),
},
});
} catch (error) {
Expand Down Expand Up @@ -343,6 +346,21 @@ export async function streamGemini(
}
}

function geminiThinkingConfig(
model: string,
enableThinking: boolean,
reasoningEffort?: StreamChatParams["reasoningEffort"],
) {
if (!modelCapability(model)?.reasoningEfforts.length) return undefined;
if (!enableThinking) return { thinkingBudget: 0 };
const effort = reasoningEffort ?? "medium";
if (effort === "none") return { thinkingBudget: 0 };
return {
includeThoughts: true,
thinkingLevel: effort.toUpperCase(),
} as unknown as Record<string, unknown>;
}

export async function completeGeminiText(params: {
model: string;
systemPrompt?: string;
Expand Down
Loading