diff --git a/packages/backend/server/src/__tests__/__snapshots__/copilot.spec.ts.md b/packages/backend/server/src/__tests__/__snapshots__/copilot.spec.ts.md index 0797e8989..38fc2496b 100644 --- a/packages/backend/server/src/__tests__/__snapshots__/copilot.spec.ts.md +++ b/packages/backend/server/src/__tests__/__snapshots__/copilot.spec.ts.md @@ -467,9 +467,9 @@ Generated by [AVA](https://avajs.dev). 'gemini-2.5-flash' -> should pick first pro model when no requested model during active +> should pick default model when no requested model during active - 'gemini-2.5-pro' + 'gemini-2.5-flash' > should honor requested pro model during active diff --git a/packages/backend/server/src/__tests__/__snapshots__/copilot.spec.ts.snap b/packages/backend/server/src/__tests__/__snapshots__/copilot.spec.ts.snap index cd677a05b..2933e7d0e 100644 Binary files a/packages/backend/server/src/__tests__/__snapshots__/copilot.spec.ts.snap and b/packages/backend/server/src/__tests__/__snapshots__/copilot.spec.ts.snap differ diff --git a/packages/backend/server/src/__tests__/copilot.spec.ts b/packages/backend/server/src/__tests__/copilot.spec.ts index 6a3b69e75..602ff7fc9 100644 --- a/packages/backend/server/src/__tests__/copilot.spec.ts +++ b/packages/backend/server/src/__tests__/copilot.spec.ts @@ -2129,13 +2129,13 @@ test('should resolve model correctly based on subscription status and prompt con ); } - // payment enabled + active: without requested -> first pro; requested pro should be honored + // payment enabled + active: without requested -> default model; requested pro should be honored { mockStatus(SubscriptionStatus.Active); const model6 = await s.resolveModel(true); t.snapshot( model6, - 'should pick first pro model when no requested model during active' + 'should pick default model when no requested model during active' ); const model7 = await s.resolveModel(true, 'claude-sonnet-4@20250514'); diff --git a/packages/backend/server/src/plugins/copilot/providers/anthropic/official.ts b/packages/backend/server/src/plugins/copilot/providers/anthropic/official.ts index ce2ac59ac..efdc4a527 100644 --- a/packages/backend/server/src/plugins/copilot/providers/anthropic/official.ts +++ b/packages/backend/server/src/plugins/copilot/providers/anthropic/official.ts @@ -21,6 +21,7 @@ export class AnthropicOfficialProvider extends AnthropicProvider { readonly models = [ // Text to Text models { + name: 'GPT 4o', id: 'gpt-4o', capabilities: [ { @@ -101,6 +102,7 @@ export class OpenAIProvider extends CopilotProvider { }, // FIXME(@darkskygit): deprecated { + name: 'GPT 4o 2024-08-06', id: 'gpt-4o-2024-08-06', capabilities: [ { @@ -110,6 +112,7 @@ export class OpenAIProvider extends CopilotProvider { ], }, { + name: 'GPT 4o Mini', id: 'gpt-4o-mini', capabilities: [ { @@ -120,6 +123,7 @@ export class OpenAIProvider extends CopilotProvider { }, // FIXME(@darkskygit): deprecated { + name: 'GPT 4o Mini 2024-07-18', id: 'gpt-4o-mini-2024-07-18', capabilities: [ { @@ -129,6 +133,7 @@ export class OpenAIProvider extends CopilotProvider { ], }, { + name: 'GPT 4.1', id: 'gpt-4.1', capabilities: [ { @@ -143,6 +148,7 @@ export class OpenAIProvider extends CopilotProvider { ], }, { + name: 'GPT 4.1 2025-04-14', id: 'gpt-4.1-2025-04-14', capabilities: [ { @@ -156,6 +162,7 @@ export class OpenAIProvider extends CopilotProvider { ], }, { + name: 'GPT 4.1 Mini', id: 'gpt-4.1-mini', capabilities: [ { @@ -169,6 +176,7 @@ export class OpenAIProvider extends CopilotProvider { ], }, { + name: 'GPT 4.1 Nano', id: 'gpt-4.1-nano', capabilities: [ { @@ -182,6 +190,7 @@ export class OpenAIProvider extends CopilotProvider { ], }, { + name: 'GPT 5', id: 'gpt-5', capabilities: [ { @@ -195,6 +204,7 @@ export class OpenAIProvider extends CopilotProvider { ], }, { + name: 'GPT 5 2025-08-07', id: 'gpt-5-2025-08-07', capabilities: [ { @@ -208,6 +218,7 @@ export class OpenAIProvider extends CopilotProvider { ], }, { + name: 'GPT 5 Mini', id: 'gpt-5-mini', capabilities: [ { @@ -221,6 +232,7 @@ export class OpenAIProvider extends CopilotProvider { ], }, { + name: 'GPT 5 Nano', id: 'gpt-5-nano', capabilities: [ { @@ -234,6 +246,7 @@ export class OpenAIProvider extends CopilotProvider { ], }, { + name: 'GPT O1', id: 'o1', capabilities: [ { @@ -243,6 +256,7 @@ export class OpenAIProvider extends CopilotProvider { ], }, { + name: 'GPT O3', id: 'o3', capabilities: [ { @@ -252,6 +266,7 @@ export class OpenAIProvider extends CopilotProvider { ], }, { + name: 'GPT O4 Mini', id: 'o4-mini', capabilities: [ { diff --git a/packages/backend/server/src/plugins/copilot/resolver.ts b/packages/backend/server/src/plugins/copilot/resolver.ts index 9f356f70e..85f53e616 100644 --- a/packages/backend/server/src/plugins/copilot/resolver.ts +++ b/packages/backend/server/src/plugins/copilot/resolver.ts @@ -362,6 +362,27 @@ class CopilotPromptType { messages!: CopilotPromptMessageType[]; } +@ObjectType() +class CopilotModelType { + @Field(() => String) + id!: string; + + @Field(() => String) + name!: string; +} + +@ObjectType() +export class CopilotModelsType { + @Field(() => String) + defaultModel!: string; + + @Field(() => [CopilotModelType]) + optionalModels!: CopilotModelType[]; + + @Field(() => [CopilotModelType]) + proModels!: CopilotModelType[]; +} + @ObjectType() export class CopilotSessionType { @Field(() => ID) @@ -400,9 +421,12 @@ export class CopilotType { @Throttle() @Resolver(() => CopilotType) export class CopilotResolver { + private readonly modelNames = new Map(); + constructor( private readonly ac: AccessController, private readonly mutex: RequestMutex, + private readonly prompt: PromptService, private readonly chatSession: ChatSessionService, private readonly storage: CopilotStorage, private readonly docReader: DocReader, @@ -443,6 +467,48 @@ export class CopilotResolver { return { userId: user.id, workspaceId, docId: docId || undefined }; } + @ResolveField(() => CopilotModelsType, { + description: + 'List available models for a prompt, with human-readable names', + complexity: 2, + }) + async models( + @Args('promptName') promptName: string + ): Promise { + const prompt = await this.prompt.get(promptName); + if (!prompt) { + throw new NotFoundException('Prompt not found'); + } + const convertModels = (ids: string[]) => { + return ids + .map(id => ({ id, name: this.modelNames.get(id) })) + .filter(m => !!m.name) as CopilotModelType[]; + }; + const proModels = prompt.config?.proModels || []; + const missing = new Set( + [...prompt.optionalModels, ...proModels].filter( + id => !this.modelNames.has(id) + ) + ); + if (missing.size) { + for (const model of missing) { + if (this.modelNames.has(model)) continue; + const provider = await this.providerFactory.getProviderByModel(model); + if (provider?.configured()) { + for (const m of provider.models) { + if (m.name) this.modelNames.set(m.id, m.name); + } + } + } + } + + return { + defaultModel: prompt.model, + optionalModels: convertModels(prompt.optionalModels), + proModels: convertModels(proModels), + }; + } + @ResolveField(() => CopilotSessionType, { description: 'Get the session by id', complexity: 2, diff --git a/packages/backend/server/src/plugins/copilot/session.ts b/packages/backend/server/src/plugins/copilot/session.ts index 6ce10c5ad..9c37007fc 100644 --- a/packages/backend/server/src/plugins/copilot/session.ts +++ b/packages/backend/server/src/plugins/copilot/session.ts @@ -130,15 +130,8 @@ export class ChatSession implements AsyncDisposable { paymentEnabled = false; } - if (paymentEnabled) { - if (isUserAIPro) { - if (!requestedModelId) { - const firstPro = this.proModels[0]; - return normalize(firstPro); - } - } else if (isPro(requestedModelId)) { - return defaultModel; - } + if (paymentEnabled && !isUserAIPro && isPro(requestedModelId)) { + return defaultModel; } return normalize(requestedModelId); diff --git a/packages/backend/server/src/schema.gql b/packages/backend/server/src/schema.gql index 61a011bfd..15551a45b 100644 --- a/packages/backend/server/src/schema.gql +++ b/packages/backend/server/src/schema.gql @@ -226,6 +226,9 @@ type Copilot { contexts(contextId: String, sessionId: String): [CopilotContext!]! histories(docId: String, options: QueryChatHistoriesInput): [CopilotHistories!]! @deprecated(reason: "use `chats` instead") + """List available models for a prompt, with human-readable names""" + models(promptName: String!): CopilotModelsType! + """Get the quota of the user in the workspace""" quota: CopilotQuota! @@ -360,6 +363,17 @@ type CopilotMessageNotFoundDataType { messageId: String! } +type CopilotModelType { + id: String! + name: String! +} + +type CopilotModelsType { + defaultModel: String! + optionalModels: [CopilotModelType!]! + proModels: [CopilotModelType!]! +} + input CopilotPromptConfigInput { frequencyPenalty: Float presencePenalty: Float diff --git a/packages/common/graphql/src/graphql/copilot-models-get.gql b/packages/common/graphql/src/graphql/copilot-models-get.gql new file mode 100644 index 000000000..4c15fa3bf --- /dev/null +++ b/packages/common/graphql/src/graphql/copilot-models-get.gql @@ -0,0 +1,17 @@ +query getPromptModels($promptName: String!) { + currentUser { + copilot { + models(promptName: $promptName) { + defaultModel + optionalModels { + id + name + } + proModels { + id + name + } + } + } + } +} diff --git a/packages/common/graphql/src/graphql/index.ts b/packages/common/graphql/src/graphql/index.ts index 325def15d..4ad96df00 100644 --- a/packages/common/graphql/src/graphql/index.ts +++ b/packages/common/graphql/src/graphql/index.ts @@ -1059,6 +1059,28 @@ export const createCopilotMessageMutation = { file: true, }; +export const getPromptModelsQuery = { + id: 'getPromptModelsQuery' as const, + op: 'getPromptModels', + query: `query getPromptModels($promptName: String!) { + currentUser { + copilot { + models(promptName: $promptName) { + defaultModel + optionalModels { + id + name + } + proModels { + id + name + } + } + } + } +}`, +}; + export const copilotQuotaQuery = { id: 'copilotQuotaQuery' as const, op: 'copilotQuota', diff --git a/packages/common/graphql/src/schema.ts b/packages/common/graphql/src/schema.ts index 198d30279..541cb9fb6 100644 --- a/packages/common/graphql/src/schema.ts +++ b/packages/common/graphql/src/schema.ts @@ -263,6 +263,8 @@ export interface Copilot { contexts: Array; /** @deprecated use `chats` instead */ histories: Array; + /** List available models for a prompt, with human-readable names */ + models: CopilotModelsType; /** Get the quota of the user in the workspace */ quota: CopilotQuota; /** Get the session by id */ @@ -296,6 +298,10 @@ export interface CopilotHistoriesArgs { options?: InputMaybe; } +export interface CopilotModelsArgs { + promptName: Scalars['String']['input']; +} + export interface CopilotSessionArgs { sessionId: Scalars['String']['input']; } @@ -451,6 +457,19 @@ export interface CopilotMessageNotFoundDataType { messageId: Scalars['String']['output']; } +export interface CopilotModelType { + __typename?: 'CopilotModelType'; + id: Scalars['String']['output']; + name: Scalars['String']['output']; +} + +export interface CopilotModelsType { + __typename?: 'CopilotModelsType'; + defaultModel: Scalars['String']['output']; + optionalModels: Array; + proModels: Array; +} + export interface CopilotPromptConfigInput { frequencyPenalty?: InputMaybe; presencePenalty?: InputMaybe; @@ -4343,6 +4362,34 @@ export type CreateCopilotMessageMutation = { createCopilotMessage: string; }; +export type GetPromptModelsQueryVariables = Exact<{ + promptName: Scalars['String']['input']; +}>; + +export type GetPromptModelsQuery = { + __typename?: 'Query'; + currentUser: { + __typename?: 'UserType'; + copilot: { + __typename?: 'Copilot'; + models: { + __typename?: 'CopilotModelsType'; + defaultModel: string; + optionalModels: Array<{ + __typename?: 'CopilotModelType'; + id: string; + name: string; + }>; + proModels: Array<{ + __typename?: 'CopilotModelType'; + id: string; + name: string; + }>; + }; + }; + } | null; +}; + export type CopilotQuotaQueryVariables = Exact<{ [key: string]: never }>; export type CopilotQuotaQuery = { @@ -6380,6 +6427,11 @@ export type Queries = variables: GetAudioTranscriptionQueryVariables; response: GetAudioTranscriptionQuery; } + | { + name: 'getPromptModelsQuery'; + variables: GetPromptModelsQueryVariables; + response: GetPromptModelsQuery; + } | { name: 'copilotQuotaQuery'; variables: CopilotQuotaQueryVariables;