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
38 changes: 28 additions & 10 deletions src/core/agent/ProviderConfigManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,9 +200,11 @@ export class ProviderConfigManager {
}
}

private async promptProviderSelection(): Promise<void> {
private async promptProviderSelection(excludedProvider?: ProviderName): Promise<void> {
// Use ProviderFactory to get platform-aware list (includes MLX on Apple Silicon).
const allProviders = ProviderFactory.getProviderNames(this.runtime.config);
const allProviders = ProviderFactory.getProviderNames(this.runtime.config).filter(
provider => provider !== excludedProvider,
);

const options = buildProviderSelectionOptions({
providers: allProviders,
Expand Down Expand Up @@ -2505,21 +2507,37 @@ export class ProviderConfigManager {
return;
}

const wasActive = this.getActiveProvider() === provider;
const accountToken = this.runtime.config.auth?.token ?? this.runtime.config.autohandai?.accountToken;
if (wasActive && !accountToken) {
await this.promptProviderSelection(provider);
if (this.getActiveProvider() === provider) return;
}

const nextCustomProviders = { ...(this.runtime.config.customProviders ?? {}) };
delete nextCustomProviders[id];
this.runtime.config.customProviders =
Object.keys(nextCustomProviders).length > 0 ? nextCustomProviders : undefined;

this.runtime.config.provider = "openrouter";
const fallbackModel =
getProviderConfig(this.runtime.config, "openrouter")?.model ??
getProviderDefaultModel("openrouter", "openrouter/auto");
this.runtime.options.model = fallbackModel;
if (wasActive && accountToken) {
const settings = this.runtime.config.autohandai;
const fallbackModel = (settings?.plan !== "local" ? settings?.model : undefined)
?? getProviderDefaultModel("autohandai", "fantail");
this.runtime.config.autohandai = {
...settings,
plan: "cloud",
authMode: "account",
accountToken,
model: fallbackModel,
};
this.runtime.config.provider = "autohandai";
this.runtime.options.model = fallbackModel;
this.resetLlmClient("autohandai", fallbackModel);
this.resetContextPercent();
this.emitStatus();
}

await saveConfig(this.runtime.config);
this.resetLlmClient("openrouter", fallbackModel);
this.resetContextPercent();
this.emitStatus();

console.log(
chalk.green(
Expand Down
7 changes: 5 additions & 2 deletions src/providers/AzureClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import type {
FunctionDefinition,
MultimodalMessage,
} from "../types.js";
import { usesMaxCompletionTokens } from "./openAICompatibleShared.js";
import { AzureTokenManager } from "./azure/tokenManager.js";
import { normalizeLLMUsage } from "./usage.js";
import { normalizeProviderFinishReason } from "./finishReason.js";
Expand Down Expand Up @@ -157,10 +158,12 @@ export class AzureClient {
}

async complete(request: LLMRequest): Promise<LLMResponse> {
const model = request.model ?? this.defaultModel;
const payload: Record<string, unknown> = {
messages: sanitizeMessages(request.messages),
temperature: request.temperature ?? 0.2,
max_tokens: request.maxTokens ?? 16000,
...(usesMaxCompletionTokens(model)
? { max_completion_tokens: request.maxTokens ?? 16000 }
: { temperature: request.temperature ?? 0.2, max_tokens: request.maxTokens ?? 16000 }),
stream: request.stream ?? false,
};

Expand Down
2 changes: 1 addition & 1 deletion src/providers/AzureProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ export class AzureProvider implements LLMProvider {
}

getCapabilities(): LLMProviderCapabilities {
return { nativeToolCalling: true };
return { nativeToolCalling: true, maxTools: 128 };
}

setModel(model: string): void {
Expand Down
21 changes: 6 additions & 15 deletions src/providers/OpenAIProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,14 @@
* SPDX-License-Identifier: Apache-2.0
*/

import type { LLMProvider } from './LLMProvider.js';
import type { LLMProvider, LLMProviderCapabilities } from './LLMProvider.js';
import type { ContentPart, MultimodalMessage, LLMRequest, LLMResponse, LLMToolCall, FunctionDefinition, ReasoningEffort, OpenAISettings, OpenAIChatGPTAuth } from '../types.js';
import { ApiError, classifyApiError, type ApiErrorCode } from './errors.js';
import { isChatGPTAuthExpired, refreshChatGPTAuth } from './openaiAuth.js';
import { normalizeLLMUsage } from './usage.js';
import { normalizeProviderFinishReason } from './finishReason.js';
import { normalizeOutboundMessages } from './messagePayload.js';
import { usesMaxCompletionTokens } from './openAICompatibleShared.js';
import {
getProviderDefaultModel,
getProviderModelIds,
Expand Down Expand Up @@ -214,9 +215,10 @@ export class OpenAIProvider implements LLMProvider {
this.model = model;
}

getCapabilities(): { nativeToolCalling: boolean } {
getCapabilities(): LLMProviderCapabilities {
return {
nativeToolCalling: true,
maxTools: 128,
};
}

Expand Down Expand Up @@ -254,13 +256,13 @@ export class OpenAIProvider implements LLMProvider {
temperature: request.temperature || 0.7,
// Newer OpenAI models (gpt-5.x, o-series) require max_completion_tokens
// instead of max_tokens. Use the correct parameter based on model.
...(this.usesMaxCompletionTokens(model)
...(usesMaxCompletionTokens(model)
? { max_completion_tokens: request.maxTokens }
: { max_tokens: request.maxTokens })
};

// Add reasoning effort when configured (with runtime validation)
if (this.usesMaxCompletionTokens(model)
if (usesMaxCompletionTokens(model)
&& this.reasoningEffort && VALID_REASONING_EFFORTS.has(this.reasoningEffort)) {
body.reasoning_effort = mustDisableChatCompletionReasoningForTools(model, hasTools)
? 'none'
Expand Down Expand Up @@ -953,17 +955,6 @@ export class OpenAIProvider implements LLMProvider {
}));
}

/**
* Determine if a model requires `max_completion_tokens` instead of `max_tokens`.
* OpenAI's newer models (gpt-5.x, o-series) reject `max_tokens` with a 400 error.
*/
private usesMaxCompletionTokens(model: string): boolean {
const lower = model.toLowerCase();
const gptGeneration = /^gpt-(\d+)(?:$|[.\-_])/.exec(lower)?.[1];
return (gptGeneration !== undefined && Number(gptGeneration) >= 5)
|| /^o[134](?:$|[-_.])/.test(lower);
}

private extractResponsesContent(data: OpenAIResponsesResponse): string {
if (typeof data.output_text === 'string' && data.output_text.trim()) {
return data.output_text;
Expand Down
2 changes: 1 addition & 1 deletion src/providers/OpenRouterProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export class OpenRouterProvider implements LLMProvider {
}

getCapabilities(): LLMProviderCapabilities {
return { nativeToolCalling: true };
return { nativeToolCalling: true, ...(this.model.toLowerCase().startsWith("openai/") ? { maxTools: 128 } : {}) };
}

setModel(model: string): void {
Expand Down
7 changes: 7 additions & 0 deletions src/providers/openAICompatibleShared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,10 @@ export function buildChatTemplateKwargs(kwargs: NvidiaChatTemplateKwargs): Recor
if (kwargs.clear_thinking !== undefined) result.clear_thinking = kwargs.clear_thinking;
return result;
}

export function usesMaxCompletionTokens(model: string): boolean {
const lower = model.toLowerCase();
const generation = /^gpt-(\d+)(?:$|[.\-_])/.exec(lower)?.[1];
return (generation !== undefined && Number(generation) >= 5)
|| /^o[134](?:$|[-_.])/.test(lower);
}
44 changes: 44 additions & 0 deletions tests/core/agent/ProviderConfigManager.providerGrouping.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -330,4 +330,48 @@ describe("ProviderConfigManager grouped provider selection", () => {

expect(configureProvider).toHaveBeenCalledWith("autohandai");
});
it("leaves the active Autohand provider and model unchanged when removing another provider", async () => {
runtime.config.provider = "autohandai";
runtime.config.autohandai = { authMode: "account", accountToken: "fixture", model: "fantail" };
runtime.options.model = "fantail";
mockShowConfirm.mockResolvedValueOnce(true);
const internals = manager as unknown as { removeCustomProvider(id: "custom:zeta-gateway"): Promise<void>; resetLlmClient(provider: string, model: string): void };
const reset = vi.spyOn(internals, "resetLlmClient").mockImplementation(() => {});
await internals.removeCustomProvider("custom:zeta-gateway");
expect(runtime.config.provider).toBe("autohandai");
expect(runtime.options.model).toBe("fantail");
expect(runtime.config.customProviders["zeta-gateway"]).toBeUndefined();
expect(reset).not.toHaveBeenCalled();
expect(mockSaveConfig).toHaveBeenCalledOnce();
});

it("falls back to the signed-in Autohand account when removing the active provider", async () => {
runtime.config.provider = "custom:zeta-gateway";
runtime.config.auth = { token: "fixture-account" };
runtime.config.autohandai = { model: "fantail" };
runtime.options.model = "zeta-large";
mockShowConfirm.mockResolvedValueOnce(true);
const internals = manager as unknown as { removeCustomProvider(id: "custom:zeta-gateway"): Promise<void>; resetLlmClient(provider: string, model: string): void };
const reset = vi.spyOn(internals, "resetLlmClient").mockImplementation(() => {});
await internals.removeCustomProvider("custom:zeta-gateway");
expect(runtime.config.provider).toBe("autohandai");
expect(runtime.options.model).toBe("fantail");
expect(runtime.config.autohandai.authMode).toBe("account");
expect(reset).toHaveBeenCalledWith("autohandai", "fantail");
});

it("keeps an active provider when a signed-out user cancels the replacement picker", async () => {
runtime.config.provider = "custom:zeta-gateway";
runtime.options.model = "zeta-large";
mockShowConfirm.mockResolvedValueOnce(true);
mockShowModal.mockResolvedValueOnce(null);
const internals = manager as unknown as { removeCustomProvider(id: "custom:zeta-gateway"): Promise<void>; resetLlmClient(provider: string, model: string): void };
vi.spyOn(internals, "resetLlmClient").mockImplementation(() => {});
await internals.removeCustomProvider("custom:zeta-gateway");
expect(runtime.config.provider).toBe("custom:zeta-gateway");
expect(runtime.config.customProviders["zeta-gateway"]).toBeDefined();
expect(lastProviderModalOptions().some(option => option.value === "custom:zeta-gateway")).toBe(false);
expect(mockSaveConfig).not.toHaveBeenCalled();
});

});
22 changes: 22 additions & 0 deletions tests/providers/AzureClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -381,4 +381,26 @@ describe('AzureClient', () => {
expect(response.finishReason).toBe('tool_calls');
});
});
describe("reasoning-model request parameters", () => {
it.each(["gpt-5.3-codex", "gpt-6.1-terra", "o3", "o4-mini"])("uses completion tokens without temperature for %s", async model => {
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(new Response(JSON.stringify({ id: "reasoning", choices: [{ message: { content: "Done", tool_calls: [{ id: "call_1", type: "function", function: { name: "read_file", arguments: "{}" } }] }, finish_reason: "tool_calls" }] })));
const client = new AzureClient({ model, resourceName: "fixture", deploymentName: "custom-deployment", apiKey: "fixture", authMethod: "api-key" });
const response = await client.complete({ messages: [{ role: "user", content: "Read the file" }], maxTokens: 4096, temperature: 0.2, tools: [{ name: "read_file", description: "Read a file", parameters: { type: "object", properties: {} } }] });
const payload = JSON.parse(String(fetchSpy.mock.calls[0][1]?.body));
expect(payload.max_completion_tokens).toBe(4096);
expect(payload).not.toHaveProperty("max_tokens");
expect(payload).not.toHaveProperty("temperature");
expect(response.toolCalls?.[0].function.name).toBe("read_file");
});
it("uses a request model override and keeps gpt-4o parameters unchanged", async () => {
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(JSON.stringify({ id: "legacy", choices: [{ message: { content: "Done" }, finish_reason: "stop" }] })));
const client = new AzureClient({ model: "gpt-5.3-codex", resourceName: "fixture", deploymentName: "custom-deployment", apiKey: "fixture", authMethod: "api-key" });
await client.complete({ model: "gpt-4o", messages: [{ role: "user", content: "Hi" }], temperature: 0 });
const payload = JSON.parse(String(fetchSpy.mock.calls[0][1]?.body));
expect(payload.max_tokens).toBe(16000);
expect(payload.temperature).toBe(0);
expect(payload).not.toHaveProperty("max_completion_tokens");
});
});

});
15 changes: 4 additions & 11 deletions tests/providers/nativeToolCapabilities.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,16 +110,9 @@ describe('native tool capability declarations', () => {
},
];

expect(
providers.map(({ name, provider }) => ({
name,
capabilities: provider.getCapabilities?.(),
})),
).toEqual(
providers.map(({ name }) => ({
name,
capabilities: { nativeToolCalling: true },
})),
);
for (const { name, provider } of providers) {
expect(provider.getCapabilities?.(), name).toMatchObject({ nativeToolCalling: true });
}

});
});
34 changes: 34 additions & 0 deletions tests/providers/openaiToolLimits.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { OpenAIProvider } from "../../src/providers/OpenAIProvider.js";
import { AzureProvider } from "../../src/providers/AzureProvider.js";
import { OpenRouterProvider } from "../../src/providers/OpenRouterProvider.js";
import { limitToolSelection } from "../../src/core/toolFilter.js";
import type { LLMProvider } from "../../src/providers/LLMProvider.js";

const tools = Array.from({ length: 140 }, (_, i) => ({ name: `fixture_${i}`, description: "Fixture tool", parameters: { type: "object", properties: {} } }));
const providers: [string, () => LLMProvider][] = [
["openai", () => new OpenAIProvider({ model: "gpt-6.1-terra", apiKey: "fixture" })],
["azure", () => new AzureProvider({ model: "gpt-5.3-codex", apiKey: "fixture", resourceName: "fixture", deploymentName: "fixture" })],
["openrouter", () => new OpenRouterProvider({ model: "openai/gpt-6.1-terra", apiKey: "fixture" })],
];
afterEach(() => vi.restoreAllMocks());
describe("OpenAI-family tool limits", () => {
it.each(providers)("sends 128 of 140 native tools through %s", async (_name, create) => {
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(JSON.stringify({ id: "fixture", choices: [{ message: { content: "Done" }, finish_reason: "stop" }], usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 } }), { headers: { "Content-Type": "application/json" } }));
const provider = create();
const messages = [{ role: "user" as const, content: "Read the project" }];
const selected = limitToolSelection(tools, tools, messages, provider.getCapabilities?.().maxTools);
await provider.complete({ messages, tools: selected, stream: false });
const init = fetchSpy.mock.calls[0][1];
const payload = JSON.parse(String(init?.body));
expect(payload.tools).toHaveLength(128);
});
it("changes the OpenRouter limit with the model and leaves other vendors uncapped", () => {
const provider = new OpenRouterProvider({ model: "anthropic/claude-sonnet-5", apiKey: "fixture" });
expect(provider.getCapabilities().maxTools).toBeUndefined();
provider.setModel("openai/gpt-6.1-terra");
expect(provider.getCapabilities().maxTools).toBe(128);
provider.setModel("anthropic/claude-sonnet-5");
expect(provider.getCapabilities().maxTools).toBeUndefined();
});
});
20 changes: 20 additions & 0 deletions tests/tuistory/model-provider-groups.tuistory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -517,4 +517,24 @@ describe('/model provider and Autohand plan journeys Tuistory', () => {
await session.press('escape');
await closeInteractive(session);
});
it('removes an inactive custom provider without switching the active hosted model', async () => {
const { session, state } = await launchInteractive(configuredAutohandCloud);
await openProviderList(session);
await session.press('up');
await session.press('up');
await session.press('enter');
await session.text({ timeout: 30_000, waitFor: text => text.includes('Remove custom provider') });
await session.type('5');
await session.text({ timeout: 30_000, waitFor: text => text.includes('Remove Acme Gateway from your custom providers?') });
await session.press('up');
await session.press('enter');
const composer = await session.text({ timeout: 30_000, waitFor: text => text.includes('Acme Gateway') && text.includes('removed') && text.includes('❯') });
expect(composer).toContain('Autohand AI, fantail');
const saved = JSON.parse(await readFile(state.configPath, 'utf8'));
expect(saved.provider).toBe('autohandai');
expect(saved.autohandai.model).toBe('fantail');
expect(saved.customProviders?.['acme-gateway']).toBeUndefined();
await closeInteractive(session);
});

});
Loading