From 9e027e09623178e2a166f058a152221c9e07f667 Mon Sep 17 00:00:00 2001 From: Ammar Date: Sat, 20 Dec 2025 11:52:22 -0600 Subject: [PATCH] =?UTF-8?q?=F0=9F=A4=96=20fix:=20auto-naming=20works=20wit?= =?UTF-8?q?h=20mux-gateway?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When users only have mux-gateway configured (no direct Anthropic/OpenAI API keys), auto-naming now works by trying gateway versions of preferred models. The fix uses clean separation of concerns: - Frontend builds preferredModels list with both direct and gateway formats - Backend just tries each model in order via findAvailableModel() - No gateway awareness needed in backend This ensures auto-naming works for gateway-only users while maintaining optimal performance for users with direct API keys. --- _Generated with `mux` • Model: `anthropic:claude-opus-4-5` • Thinking: `high`_ --- src/browser/hooks/useGatewayModels.ts | 15 ++++++ src/browser/hooks/useWorkspaceName.ts | 21 ++++++++- src/common/orpc/schemas/api.ts | 4 +- src/node/orpc/router.ts | 8 ++-- .../services/workspaceTitleGenerator.test.ts | 47 ++++++++++++------- src/node/services/workspaceTitleGenerator.ts | 18 ++++--- 6 files changed, 80 insertions(+), 33 deletions(-) diff --git a/src/browser/hooks/useGatewayModels.ts b/src/browser/hooks/useGatewayModels.ts index 3a2368b2ba..0636409f55 100644 --- a/src/browser/hooks/useGatewayModels.ts +++ b/src/browser/hooks/useGatewayModels.ts @@ -46,6 +46,21 @@ export function isGatewayFormat(modelId: string): boolean { return modelId.startsWith("mux-gateway:"); } +/** + * Convert a canonical model string to mux-gateway format. + * Example: "anthropic:claude-haiku-4-5" → "mux-gateway:anthropic/claude-haiku-4-5" + * + * Unlike toGatewayModel(), this doesn't check if the user enabled gateway for + * this specific model - use it when gateway should be used unconditionally + * (e.g., for name generation with small models). + */ +export function formatAsGatewayModel(modelId: string): string { + const provider = getProvider(modelId); + if (!provider) return modelId; + const model = modelId.slice(provider.length + 1); + return `mux-gateway:${provider}/${model}`; +} + /** * Migrate a mux-gateway model to canonical format and enable gateway toggle. * Converts "mux-gateway:provider/model" to "provider:model" and marks it for gateway routing. diff --git a/src/browser/hooks/useWorkspaceName.ts b/src/browser/hooks/useWorkspaceName.ts index 31ca1d149b..5a807a857f 100644 --- a/src/browser/hooks/useWorkspaceName.ts +++ b/src/browser/hooks/useWorkspaceName.ts @@ -1,5 +1,7 @@ import { useState, useRef, useCallback, useEffect, useMemo } from "react"; import { useAPI } from "@/browser/contexts/API"; +import { useGateway, formatAsGatewayModel } from "@/browser/hooks/useGatewayModels"; +import { getKnownModel } from "@/common/constants/knownModels"; export interface UseWorkspaceNameOptions { /** The user's message to generate a name for */ @@ -48,9 +50,25 @@ export interface UseWorkspaceNameReturn extends WorkspaceNameState { * but allows manual override. If the user clears the manual name, * auto-generation resumes. */ +/** Small, fast models preferred for name generation */ +const PREFERRED_NAME_MODELS = [getKnownModel("HAIKU").id, getKnownModel("GPT_MINI").id]; + export function useWorkspaceName(options: UseWorkspaceNameOptions): UseWorkspaceNameReturn { const { message, debounceMs = 500, fallbackModel } = options; const { api } = useAPI(); + // Use global gateway availability (configured + enabled), not per-model toggles. + // Name generation uses utility models (Haiku, GPT-Mini) that users don't explicitly + // add to their model list, so we can't rely on per-model gateway settings. + const { isActive: gatewayConfigured } = useGateway(); + + // Build preferred models list: try direct first, then gateway versions if available + const preferredModels = useMemo(() => { + const models: string[] = [...PREFERRED_NAME_MODELS]; + if (gatewayConfigured) { + models.push(...PREFERRED_NAME_MODELS.map(formatAsGatewayModel)); + } + return models; + }, [gatewayConfigured]); // Generated identity (name + title) from AI const [generatedIdentity, setGeneratedIdentity] = useState(null); @@ -120,6 +138,7 @@ export function useWorkspaceName(options: UseWorkspaceNameOptions): UseWorkspace try { const result = await api.nameGeneration.generate({ message: forMessage, + preferredModels, fallbackModel, }); @@ -162,7 +181,7 @@ export function useWorkspaceName(options: UseWorkspaceNameOptions): UseWorkspace } } }, - [api, fallbackModel] + [api, preferredModels, fallbackModel] ); // Debounced generation effect diff --git a/src/common/orpc/schemas/api.ts b/src/common/orpc/schemas/api.ts index 6e25b954ef..0fb9bbfd3f 100644 --- a/src/common/orpc/schemas/api.ts +++ b/src/common/orpc/schemas/api.ts @@ -485,7 +485,9 @@ export const nameGeneration = { generate: { input: z.object({ message: z.string(), - /** Model to use if preferred small models (Haiku, GPT-Mini) aren't available */ + /** Models to try in order (frontend converts to gateway format if needed) */ + preferredModels: z.array(z.string()).optional(), + /** Model to use if preferred models aren't available */ fallbackModel: z.string().optional(), }), output: ResultSchema( diff --git a/src/node/orpc/router.ts b/src/node/orpc/router.ts index d2ceabf328..162e24fa36 100644 --- a/src/node/orpc/router.ts +++ b/src/node/orpc/router.ts @@ -2,7 +2,7 @@ import { os } from "@orpc/server"; import * as schemas from "@/common/orpc/schemas"; import type { ORPCContext } from "./context"; import { - getPreferredNameModel, + findAvailableModel, generateWorkspaceIdentity, } from "@/node/services/workspaceTitleGenerator"; import type { @@ -521,8 +521,10 @@ export const router = (authToken?: string) => { .input(schemas.nameGeneration.generate.input) .output(schemas.nameGeneration.generate.output) .handler(async ({ context, input }) => { - // Prefer small/fast models, fall back to user's configured model - const model = (await getPreferredNameModel(context.aiService)) ?? input.fallbackModel; + // Try preferred models in order, fall back to user's configured model + const model = + (await findAvailableModel(context.aiService, input.preferredModels ?? [])) ?? + input.fallbackModel; if (!model) { return { success: false, diff --git a/src/node/services/workspaceTitleGenerator.test.ts b/src/node/services/workspaceTitleGenerator.test.ts index 5200d5bad9..86b08ebd93 100644 --- a/src/node/services/workspaceTitleGenerator.test.ts +++ b/src/node/services/workspaceTitleGenerator.test.ts @@ -1,7 +1,6 @@ import { describe, it, expect } from "bun:test"; -import { getPreferredNameModel } from "./workspaceTitleGenerator"; +import { findAvailableModel } from "./workspaceTitleGenerator"; import type { AIService } from "./aiService"; -import { getKnownModel } from "@/common/constants/knownModels"; import type { LanguageModel } from "ai"; import type { Result } from "@/common/types/result"; import type { SendMessageError } from "@/common/types/errors"; @@ -13,7 +12,6 @@ function createMockAIService(availableModels: string[]): AIService { const service: Partial = { createModel: (modelString: string): Promise => { if (availableModels.includes(modelString)) { - // Return a minimal success result - data is not used by getPreferredNameModel const result: CreateModelResult = { success: true, data: null as never }; return Promise.resolve(result); } @@ -28,23 +26,36 @@ function createMockAIService(availableModels: string[]): AIService { } describe("workspaceTitleGenerator", () => { - const HAIKU_ID = getKnownModel("HAIKU").id; - const GPT_MINI_ID = getKnownModel("GPT_MINI").id; + describe("findAvailableModel", () => { + it("returns null when no models available", async () => { + const aiService = createMockAIService([]); + expect(await findAvailableModel(aiService, ["model-a", "model-b"])).toBeNull(); + }); - it("getPreferredNameModel returns null when no models available", async () => { - const aiService = createMockAIService([]); - expect(await getPreferredNameModel(aiService)).toBeNull(); - }); + it("returns null for empty models list", async () => { + const aiService = createMockAIService(["any-model"]); + expect(await findAvailableModel(aiService, [])).toBeNull(); + }); - it("getPreferredNameModel prefers Haiku when available", async () => { - const aiService = createMockAIService([HAIKU_ID, GPT_MINI_ID]); - const model = await getPreferredNameModel(aiService); - expect(model).toBe(HAIKU_ID); - }); + it("returns first available model", async () => { + const aiService = createMockAIService(["model-b", "model-c"]); + const model = await findAvailableModel(aiService, ["model-a", "model-b", "model-c"]); + expect(model).toBe("model-b"); + }); + + it("tries models in order", async () => { + const aiService = createMockAIService(["model-a", "model-b"]); + const model = await findAvailableModel(aiService, ["model-a", "model-b"]); + expect(model).toBe("model-a"); + }); - it("getPreferredNameModel falls back to GPT Mini when Haiku unavailable", async () => { - const aiService = createMockAIService([GPT_MINI_ID]); - const model = await getPreferredNameModel(aiService); - expect(model).toBe(GPT_MINI_ID); + it("works with gateway-format models", async () => { + const aiService = createMockAIService(["mux-gateway:anthropic/claude-haiku-4-5"]); + const model = await findAvailableModel(aiService, [ + "anthropic:claude-haiku-4-5", // direct - unavailable + "mux-gateway:anthropic/claude-haiku-4-5", // gateway - available + ]); + expect(model).toBe("mux-gateway:anthropic/claude-haiku-4-5"); + }); }); }); diff --git a/src/node/services/workspaceTitleGenerator.ts b/src/node/services/workspaceTitleGenerator.ts index d2356dc4c5..48e34f1ca3 100644 --- a/src/node/services/workspaceTitleGenerator.ts +++ b/src/node/services/workspaceTitleGenerator.ts @@ -5,12 +5,8 @@ import { log } from "./log"; import type { Result } from "@/common/types/result"; import { Ok, Err } from "@/common/types/result"; import type { SendMessageError } from "@/common/types/errors"; -import { getKnownModel } from "@/common/constants/knownModels"; import crypto from "crypto"; -/** Models to try in order of preference for name generation (small, fast models) */ -const PREFERRED_MODELS = [getKnownModel("HAIKU").id, getKnownModel("GPT_MINI").id] as const; - /** Schema for AI-generated workspace identity (area name + descriptive title) */ const workspaceIdentitySchema = z.object({ name: z @@ -36,17 +32,19 @@ export interface WorkspaceIdentity { } /** - * Get the preferred model for name generation by testing which models the AIService - * can actually create. This delegates credential checking to AIService, avoiding - * duplication of provider-specific API key logic. + * Find the first model from the list that the AIService can create. + * Frontend is responsible for providing models in the correct format + * (direct or gateway) based on user configuration. */ -export async function getPreferredNameModel(aiService: AIService): Promise { - for (const modelId of PREFERRED_MODELS) { +export async function findAvailableModel( + aiService: AIService, + models: string[] +): Promise { + for (const modelId of models) { const result = await aiService.createModel(modelId); if (result.success) { return modelId; } - // If it's an API key error, try the next model; other errors are also skipped } return null; }