diff --git a/src/core/agent/ProviderConfigManager.ts b/src/core/agent/ProviderConfigManager.ts index 8311d405..aac2ebba 100644 --- a/src/core/agent/ProviderConfigManager.ts +++ b/src/core/agent/ProviderConfigManager.ts @@ -200,9 +200,11 @@ export class ProviderConfigManager { } } - private async promptProviderSelection(): Promise { + private async promptProviderSelection(excludedProvider?: ProviderName): Promise { // 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, @@ -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( diff --git a/src/providers/AzureClient.ts b/src/providers/AzureClient.ts index b37d2326..550be85e 100644 --- a/src/providers/AzureClient.ts +++ b/src/providers/AzureClient.ts @@ -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"; @@ -157,10 +158,12 @@ export class AzureClient { } async complete(request: LLMRequest): Promise { + const model = request.model ?? this.defaultModel; const payload: Record = { 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, }; diff --git a/src/providers/AzureProvider.ts b/src/providers/AzureProvider.ts index d21a4a6b..65978aa3 100644 --- a/src/providers/AzureProvider.ts +++ b/src/providers/AzureProvider.ts @@ -37,7 +37,7 @@ export class AzureProvider implements LLMProvider { } getCapabilities(): LLMProviderCapabilities { - return { nativeToolCalling: true }; + return { nativeToolCalling: true, maxTools: 128 }; } setModel(model: string): void { diff --git a/src/providers/OpenAIProvider.ts b/src/providers/OpenAIProvider.ts index ac7441de..a25eb89c 100644 --- a/src/providers/OpenAIProvider.ts +++ b/src/providers/OpenAIProvider.ts @@ -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, @@ -214,9 +215,10 @@ export class OpenAIProvider implements LLMProvider { this.model = model; } - getCapabilities(): { nativeToolCalling: boolean } { + getCapabilities(): LLMProviderCapabilities { return { nativeToolCalling: true, + maxTools: 128, }; } @@ -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' @@ -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; diff --git a/src/providers/OpenRouterProvider.ts b/src/providers/OpenRouterProvider.ts index 13f7fab5..66bc922b 100644 --- a/src/providers/OpenRouterProvider.ts +++ b/src/providers/OpenRouterProvider.ts @@ -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 { diff --git a/src/providers/openAICompatibleShared.ts b/src/providers/openAICompatibleShared.ts index 37b2b734..5167b012 100644 --- a/src/providers/openAICompatibleShared.ts +++ b/src/providers/openAICompatibleShared.ts @@ -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); +} diff --git a/tests/core/agent/ProviderConfigManager.providerGrouping.test.ts b/tests/core/agent/ProviderConfigManager.providerGrouping.test.ts index 10203479..1a60e8f2 100644 --- a/tests/core/agent/ProviderConfigManager.providerGrouping.test.ts +++ b/tests/core/agent/ProviderConfigManager.providerGrouping.test.ts @@ -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; 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; 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; 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(); + }); + }); diff --git a/tests/providers/AzureClient.test.ts b/tests/providers/AzureClient.test.ts index def8b1d6..8a613c55 100644 --- a/tests/providers/AzureClient.test.ts +++ b/tests/providers/AzureClient.test.ts @@ -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"); + }); + }); + }); diff --git a/tests/providers/nativeToolCapabilities.test.ts b/tests/providers/nativeToolCapabilities.test.ts index 39c9438f..7390e8dc 100644 --- a/tests/providers/nativeToolCapabilities.test.ts +++ b/tests/providers/nativeToolCapabilities.test.ts @@ -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 }); + } + }); }); diff --git a/tests/providers/openaiToolLimits.test.ts b/tests/providers/openaiToolLimits.test.ts new file mode 100644 index 00000000..02da3ea5 --- /dev/null +++ b/tests/providers/openaiToolLimits.test.ts @@ -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(); + }); +}); diff --git a/tests/tuistory/model-provider-groups.tuistory.test.ts b/tests/tuistory/model-provider-groups.tuistory.test.ts index d5771007..24fc96d4 100644 --- a/tests/tuistory/model-provider-groups.tuistory.test.ts +++ b/tests/tuistory/model-provider-groups.tuistory.test.ts @@ -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); + }); + });