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
15 changes: 15 additions & 0 deletions src/browser/hooks/useGatewayModels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
21 changes: 20 additions & 1 deletion src/browser/hooks/useWorkspaceName.ts
Original file line number Diff line number Diff line change
@@ -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 */
Expand Down Expand Up @@ -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<WorkspaceIdentity | null>(null);
Expand Down Expand Up @@ -120,6 +138,7 @@ export function useWorkspaceName(options: UseWorkspaceNameOptions): UseWorkspace
try {
const result = await api.nameGeneration.generate({
message: forMessage,
preferredModels,
fallbackModel,
});

Expand Down Expand Up @@ -162,7 +181,7 @@ export function useWorkspaceName(options: UseWorkspaceNameOptions): UseWorkspace
}
}
},
[api, fallbackModel]
[api, preferredModels, fallbackModel]
);

// Debounced generation effect
Expand Down
4 changes: 3 additions & 1 deletion src/common/orpc/schemas/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
8 changes: 5 additions & 3 deletions src/node/orpc/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down
47 changes: 29 additions & 18 deletions src/node/services/workspaceTitleGenerator.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -13,7 +12,6 @@ function createMockAIService(availableModels: string[]): AIService {
const service: Partial<AIService> = {
createModel: (modelString: string): Promise<CreateModelResult> => {
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);
}
Expand All @@ -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");
});
});
});
18 changes: 8 additions & 10 deletions src/node/services/workspaceTitleGenerator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<string | null> {
for (const modelId of PREFERRED_MODELS) {
export async function findAvailableModel(
aiService: AIService,
models: string[]
): Promise<string | null> {
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;
}
Expand Down