From 2c650b37659880c8f18f551bee946a16d76d820d Mon Sep 17 00:00:00 2001 From: warelik <54947489+warelik@users.noreply.github.com> Date: Sat, 1 Aug 2026 10:58:19 +0300 Subject: [PATCH 1/2] fix(backend): cover cursor-agent CLI local-mode endpoint surface Desktop Cursor works through the local proxy because it drives the agent over BidiAppend/RunSSE plus the already-mocked unary endpoints. The cursor-agent CLI speaks the same agent protocol but calls additional unary endpoints that had no local handlers, so every request fell into a wildcard route and came back as HTTP 404, which the Connect client maps to '[unimplemented] HTTP 404'. Three independent breaks, one visible symptom: 1. Startup: ServerConfigService/GetServerConfig (only the AiService variant was mocked), DashboardService/GetTeamAdminSettingsOrEmptyIfNotInTeam and DashboardService/ListMarketplaces were missing, so the CLI aborted during session init. 2. Git workspaces: the CLI resolves the repo path-encryption key from indexingConfig.default{User,Team}PathEncryptionKey in GetServerConfig when no IDE-stored repo keys exist. The mock returned no indexingConfig, so repository identity init failed with 'No encryption key found'. 3. Tool execution: every fs tool executor (Ls/Grep/Glob/Shell) consults the ignore service, which calls getRepoBlockExcludeGlobs() -> DashboardService/GetTeamReposOrEmptyIfNotInTeam. The 404 propagated as the tool result error, so model answers arrived but every tool call returned '[unimplemented] HTTP 404'. Non-git workspaces skip the repo-block path, which is why tools only failed inside git repos. Also close the remaining tolerated 404 noise so a CLI session produces zero unimplemented responses: model listing (GetUsableModels, GetDefaultModelForCli, GetDefaultModel), dashboard/plugin housekeeping (GetGlobalCommands, GetEffectiveUserPlugins, RegisterMarketplaceAndPlugins, GetCliDownloadUrl) and telemetry (AnalyticsService/SubmitLogs, AnalyticsService/TrackEvents, OTLP /v1/traces). Additional logging: PolicyMiddleware now includes the request path in the per-request log line, which is what made this diagnosable from app.log. --- internal/backend/host.go | 180 +++++++++++++++++++++ internal/backend/server/middleware.go | 6 +- internal/backend/server/upstream/action.go | 24 +++ internal/backend/server/upstream/client.go | 24 +++ internal/backend/server/upstream/mocks.go | 51 ++++++ 5 files changed, 284 insertions(+), 1 deletion(-) diff --git a/internal/backend/host.go b/internal/backend/host.go index 945d6f2..29587d0 100644 --- a/internal/backend/host.go +++ b/internal/backend/host.go @@ -330,6 +330,19 @@ func (host *Host) rebuildLocked(cfg serverconfig.Config) error { Name: "server_config", })), ), + server.POST("/aiserver.v1.ServerConfigService/GetServerConfig", + server.Name("server_config_service_get_server_config"), + server.ConnectUnary(), + server.Local(upstream.MockProtoAction(routeDeps, upstream.CompatRouteConfig{ + Name: "server_config_service_get_server_config", + StatusCode: http.StatusOK, + MockProtoType: "aiserver.v1.GetServerConfigResponse", + MockBuilder: upstream.ServerConfigMockBuilder, + })), + server.Upstream(upstream.DirectAction(routeDeps, upstream.CompatRouteConfig{ + Name: "server_config_service_get_server_config", + })), + ), server.POST("/aiserver.v1.AiService/AvailableModels", server.Name("available_models"), server.ConnectUnary(), @@ -343,6 +356,45 @@ func (host *Host) rebuildLocked(cfg serverconfig.Config) error { Name: "available_models", })), ), + server.POST("/aiserver.v1.AiService/GetUsableModels", + server.Name("usable_models"), + server.ConnectUnary(), + server.Local(upstream.MockProtoAction(routeDeps, upstream.CompatRouteConfig{ + Name: "usable_models", + StatusCode: http.StatusOK, + MockProtoType: "aiserver.v1.GetUsableModelsResponse", + MockBuilder: upstream.UsableModelsMockBuilder, + })), + server.Upstream(upstream.DirectAction(routeDeps, upstream.CompatRouteConfig{ + Name: "usable_models", + })), + ), + server.POST("/aiserver.v1.AiService/GetDefaultModelForCli", + server.Name("default_model_for_cli"), + server.ConnectUnary(), + server.Local(upstream.MockProtoAction(routeDeps, upstream.CompatRouteConfig{ + Name: "default_model_for_cli", + StatusCode: http.StatusOK, + MockProtoType: "aiserver.v1.GetDefaultModelForCliResponse", + MockBuilder: upstream.DefaultModelForCliMockBuilder, + })), + server.Upstream(upstream.DirectAction(routeDeps, upstream.CompatRouteConfig{ + Name: "default_model_for_cli", + })), + ), + server.POST("/aiserver.v1.AiService/GetDefaultModel", + server.Name("default_model"), + server.ConnectUnary(), + server.Local(upstream.MockProtoAction(routeDeps, upstream.CompatRouteConfig{ + Name: "default_model", + StatusCode: http.StatusOK, + MockProtoType: "aiserver.v1.GetDefaultModelResponse", + MockBuilder: upstream.DefaultModelMockBuilder, + })), + server.Upstream(upstream.DirectAction(routeDeps, upstream.CompatRouteConfig{ + Name: "default_model", + })), + ), server.POST("/aiserver.v1.AiService/GetDefaultModelNudgeData", server.Name("default_model_nudge"), server.ConnectUnary(), @@ -382,6 +434,43 @@ func (host *Host) rebuildLocked(cfg serverconfig.Config) error { Name: "first_window_statsig_decision", })), ), + server.POST("/aiserver.v1.AnalyticsService/SubmitLogs", + server.Name("analytics_submit_logs"), + server.ConnectUnary(), + server.Local(upstream.MockProtoAction(routeDeps, upstream.CompatRouteConfig{ + Name: "analytics_submit_logs", + StatusCode: http.StatusOK, + MockProtoType: "aiserver.v1.SubmitLogsResponse", + MockBuilder: upstream.SubmitLogsMockBuilder, + })), + server.Upstream(upstream.DirectAction(routeDeps, upstream.CompatRouteConfig{ + Name: "analytics_submit_logs", + })), + ), + server.POST("/aiserver.v1.AnalyticsService/TrackEvents", + server.Name("analytics_track_events"), + server.ConnectUnary(), + server.Local(upstream.MockProtoAction(routeDeps, upstream.CompatRouteConfig{ + Name: "analytics_track_events", + StatusCode: http.StatusOK, + MockProtoType: "aiserver.v1.TrackEventsResponse", + MockBuilder: upstream.EmptyMockBuilder, + })), + server.Upstream(upstream.DirectAction(routeDeps, upstream.CompatRouteConfig{ + Name: "analytics_track_events", + })), + ), + server.POST("/v1/traces", + server.Name("otlp_traces"), + server.HTTP(), + server.Local(upstream.FixedStatusAction(routeDeps, upstream.CompatRouteConfig{ + Name: "otlp_traces", + StatusCode: http.StatusOK, + })), + server.Upstream(upstream.DirectAction(routeDeps, upstream.CompatRouteConfig{ + Name: "otlp_traces", + })), + ), server.POST("/oauth/token", server.Name("oauth_token"), server.HTTP(), @@ -525,6 +614,97 @@ func (host *Host) rebuildLocked(cfg serverconfig.Config) error { Name: "dashboard_get_managed_skills", })), ), + server.POST("/aiserver.v1.DashboardService/GetTeamAdminSettingsOrEmptyIfNotInTeam", + server.Name("dashboard_get_team_admin_settings_or_empty"), + server.ConnectUnary(), + server.Local(upstream.MockProtoAction(routeDeps, upstream.CompatRouteConfig{ + Name: "dashboard_get_team_admin_settings_or_empty", + StatusCode: http.StatusOK, + MockProtoType: "aiserver.v1.GetTeamAdminSettingsResponse", + MockBuilder: upstream.EmptyMockBuilder, + })), + server.Upstream(upstream.DirectAction(routeDeps, upstream.CompatRouteConfig{ + Name: "dashboard_get_team_admin_settings_or_empty", + })), + ), + server.POST("/aiserver.v1.DashboardService/GetTeamReposOrEmptyIfNotInTeam", + server.Name("dashboard_get_team_repos_or_empty"), + server.ConnectUnary(), + server.Local(upstream.MockProtoAction(routeDeps, upstream.CompatRouteConfig{ + Name: "dashboard_get_team_repos_or_empty", + StatusCode: http.StatusOK, + MockProtoType: "aiserver.v1.GetTeamReposResponse", + MockBuilder: upstream.EmptyMockBuilder, + })), + server.Upstream(upstream.DirectAction(routeDeps, upstream.CompatRouteConfig{ + Name: "dashboard_get_team_repos_or_empty", + })), + ), + server.POST("/aiserver.v1.DashboardService/ListMarketplaces", + server.Name("dashboard_list_marketplaces"), + server.ConnectUnary(), + server.Local(upstream.MockProtoAction(routeDeps, upstream.CompatRouteConfig{ + Name: "dashboard_list_marketplaces", + StatusCode: http.StatusOK, + MockProtoType: "aiserver.v1.ListMarketplacesResponse", + MockBuilder: upstream.EmptyMockBuilder, + })), + server.Upstream(upstream.DirectAction(routeDeps, upstream.CompatRouteConfig{ + Name: "dashboard_list_marketplaces", + })), + ), + server.POST("/aiserver.v1.DashboardService/GetGlobalCommands", + server.Name("dashboard_get_global_commands"), + server.ConnectUnary(), + server.Local(upstream.MockProtoAction(routeDeps, upstream.CompatRouteConfig{ + Name: "dashboard_get_global_commands", + StatusCode: http.StatusOK, + MockProtoType: "aiserver.v1.GetGlobalCommandsResponse", + MockBuilder: upstream.EmptyMockBuilder, + })), + server.Upstream(upstream.DirectAction(routeDeps, upstream.CompatRouteConfig{ + Name: "dashboard_get_global_commands", + })), + ), + server.POST("/aiserver.v1.DashboardService/GetEffectiveUserPlugins", + server.Name("dashboard_get_effective_user_plugins"), + server.ConnectUnary(), + server.Local(upstream.MockProtoAction(routeDeps, upstream.CompatRouteConfig{ + Name: "dashboard_get_effective_user_plugins", + StatusCode: http.StatusOK, + MockProtoType: "aiserver.v1.GetEffectiveUserPluginsResponse", + MockBuilder: upstream.EmptyMockBuilder, + })), + server.Upstream(upstream.DirectAction(routeDeps, upstream.CompatRouteConfig{ + Name: "dashboard_get_effective_user_plugins", + })), + ), + server.POST("/aiserver.v1.DashboardService/RegisterMarketplaceAndPlugins", + server.Name("dashboard_register_marketplace_and_plugins"), + server.ConnectUnary(), + server.Local(upstream.MockProtoAction(routeDeps, upstream.CompatRouteConfig{ + Name: "dashboard_register_marketplace_and_plugins", + StatusCode: http.StatusOK, + MockProtoType: "aiserver.v1.RegisterMarketplaceAndPluginsResponse", + MockBuilder: upstream.EmptyMockBuilder, + })), + server.Upstream(upstream.DirectAction(routeDeps, upstream.CompatRouteConfig{ + Name: "dashboard_register_marketplace_and_plugins", + })), + ), + server.POST("/aiserver.v1.DashboardService/GetCliDownloadUrl", + server.Name("dashboard_get_cli_download_url"), + server.ConnectUnary(), + server.Local(upstream.MockProtoAction(routeDeps, upstream.CompatRouteConfig{ + Name: "dashboard_get_cli_download_url", + StatusCode: http.StatusOK, + MockProtoType: "aiserver.v1.GetCliDownloadUrlResponse", + MockBuilder: upstream.EmptyMockBuilder, + })), + server.Upstream(upstream.DirectAction(routeDeps, upstream.CompatRouteConfig{ + Name: "dashboard_get_cli_download_url", + })), + ), server.POST("/aiserver.v1.DashboardService/GetMe", server.Name("dashboard_get_me"), server.ConnectUnary(), diff --git a/internal/backend/server/middleware.go b/internal/backend/server/middleware.go index 2ab33c1..bdb3d07 100644 --- a/internal/backend/server/middleware.go +++ b/internal/backend/server/middleware.go @@ -43,7 +43,11 @@ func PolicyMiddleware(configs *serverconfig.Manager) Middleware { return func(next HandlerFunc) HandlerFunc { return func(ctx *Context) error { ctx.Mode = parseExecutionMode(configs.RouteMode(ctx.UpstreamURL != nil)) - logger.Infof("ctx.Mode=%s upstream=%t", ctx.Mode, ctx.UpstreamURL != nil) + path := "" + if ctx.Request != nil && ctx.Request.URL != nil { + path = ctx.Request.URL.Path + } + logger.Infof("ctx.Mode=%s upstream=%t path=%s", ctx.Mode, ctx.UpstreamURL != nil, path) return next(ctx) } } diff --git a/internal/backend/server/upstream/action.go b/internal/backend/server/upstream/action.go index c0ecff3..500234a 100644 --- a/internal/backend/server/upstream/action.go +++ b/internal/backend/server/upstream/action.go @@ -165,6 +165,18 @@ func DefaultModelNudgeMockBuilder(reqCtx *RequestContext) (map[string]any, error return buildDefaultModelNudgeDataPayload(reqCtx) } +func UsableModelsMockBuilder(reqCtx *RequestContext) (map[string]any, error) { + return buildUsableModelsPayload(reqCtx) +} + +func DefaultModelForCliMockBuilder(reqCtx *RequestContext) (map[string]any, error) { + return buildDefaultModelForCliPayload(reqCtx) +} + +func DefaultModelMockBuilder(reqCtx *RequestContext) (map[string]any, error) { + return buildDefaultModelPayload(reqCtx) +} + func BootstrapStatsigMockBuilder(reqCtx *RequestContext) (map[string]any, error) { return buildBootstrapStatsigPayload(reqCtx) } @@ -185,6 +197,18 @@ func DashboardManagedSkillsMockBuilder(reqCtx *RequestContext) (map[string]any, return buildDashboardManagedSkillsPayload(reqCtx) } +// EmptyMockBuilder возвращает пустой proto-ответ для ручек, где клиенту +// достаточно успешного "пусто": нет team-настроек, нет репозиториев, +// нет маркетплейсов/плагинов/команд, телеметрия принята без обработки. +func EmptyMockBuilder(reqCtx *RequestContext) (map[string]any, error) { + return map[string]any{}, nil +} + +// SubmitLogsMockBuilder подтверждает приём логов телеметрии без обработки. +func SubmitLogsMockBuilder(reqCtx *RequestContext) (map[string]any, error) { + return map[string]any{"success": true}, nil +} + func DashboardGetMeMockBuilder(reqCtx *RequestContext) (map[string]any, error) { return buildDashboardGetMePayload(reqCtx) } diff --git a/internal/backend/server/upstream/client.go b/internal/backend/server/upstream/client.go index 218c234..ba2c87e 100644 --- a/internal/backend/server/upstream/client.go +++ b/internal/backend/server/upstream/client.go @@ -427,6 +427,30 @@ func newProtoMessage(typeName string) (proto.Message, error) { return &aiserverv1.GetUsageLimitStatusAndActiveGrantsResponse{}, nil case "aiserver.v1.IsOnNewPricingResponse": return &aiserverv1.IsOnNewPricingResponse{}, nil + case "aiserver.v1.GetTeamAdminSettingsResponse": + return &aiserverv1.GetTeamAdminSettingsResponse{}, nil + case "aiserver.v1.GetTeamReposResponse": + return &aiserverv1.GetTeamReposResponse{}, nil + case "aiserver.v1.ListMarketplacesResponse": + return &aiserverv1.ListMarketplacesResponse{}, nil + case "aiserver.v1.GetUsableModelsResponse": + return &aiserverv1.GetUsableModelsResponse{}, nil + case "aiserver.v1.GetDefaultModelForCliResponse": + return &aiserverv1.GetDefaultModelForCliResponse{}, nil + case "aiserver.v1.GetDefaultModelResponse": + return &aiserverv1.GetDefaultModelResponse{}, nil + case "aiserver.v1.GetGlobalCommandsResponse": + return &aiserverv1.GetGlobalCommandsResponse{}, nil + case "aiserver.v1.GetEffectiveUserPluginsResponse": + return &aiserverv1.GetEffectiveUserPluginsResponse{}, nil + case "aiserver.v1.RegisterMarketplaceAndPluginsResponse": + return &aiserverv1.RegisterMarketplaceAndPluginsResponse{}, nil + case "aiserver.v1.GetCliDownloadUrlResponse": + return &aiserverv1.GetCliDownloadUrlResponse{}, nil + case "aiserver.v1.SubmitLogsResponse": + return &aiserverv1.SubmitLogsResponse{}, nil + case "aiserver.v1.TrackEventsResponse": + return &aiserverv1.TrackEventsResponse{}, nil default: return nil, fmt.Errorf("unsupported proto message type %q", typeName) } diff --git a/internal/backend/server/upstream/mocks.go b/internal/backend/server/upstream/mocks.go index c33dcfc..c299104 100644 --- a/internal/backend/server/upstream/mocks.go +++ b/internal/backend/server/upstream/mocks.go @@ -17,6 +17,13 @@ const ( modelRuntimeThinkingEffortParameterID = "thinking_effort" + // localPathEncryptionKey — стабильный ключ шифрования путей для индексации + // репозитория, который cursor-agent CLI запрашивает через GetServerConfig + // (indexingConfig.default{User,Team}PathEncryptionKey). Без него CLI + // не может инициализировать repo identity в git-воркспейсе и вызовы + // файловых инструментов падают с "[unimplemented] HTTP 404". + localPathEncryptionKey = "6f6e63652d6c6f63616c2d706174682d656e6372797074696f6e2d6b6579" + localUltraMembershipType = "ultra" localUltraPaymentID = "local_ultra" localUltraSubscriptionStatus = "active" @@ -427,6 +434,10 @@ func buildServerConfigPayload(*RequestContext) (map[string]any, error) { "configVersion": "local_cli_sandbox_defaults_disabled_v2", // "http2Config": "HTTP2_CONFIG_FORCE_ALL_DISABLED", "cliSandboxDefaultEnabled": true, + "indexingConfig": map[string]any{ + "defaultUserPathEncryptionKey": localPathEncryptionKey, + "defaultTeamPathEncryptionKey": localPathEncryptionKey, + }, }, nil } @@ -486,6 +497,36 @@ func buildDefaultModelNudgeDataPayload(reqCtx *RequestContext) (map[string]any, }, nil } +func buildUsableModelsPayload(reqCtx *RequestContext) (map[string]any, error) { + adapters, err := loadConfiguredModelAdapters(reqCtx) + if err != nil { + return nil, err + } + refs := collectModelAdapterRefs(adapters) + models := make([]map[string]any, 0, len(refs)) + for _, ref := range refs { + models = append(models, map[string]any{"modelName": ref}) + } + return map[string]any{"models": models}, nil +} + +func buildDefaultModelForCliPayload(reqCtx *RequestContext) (map[string]any, error) { + adapters, err := loadConfiguredModelAdapters(reqCtx) + if err != nil { + return nil, err + } + return map[string]any{"model": map[string]any{"modelName": firstModelAdapterRef(adapters)}}, nil +} + +func buildDefaultModelPayload(reqCtx *RequestContext) (map[string]any, error) { + adapters, err := loadConfiguredModelAdapters(reqCtx) + if err != nil { + return nil, err + } + defaultModel := firstModelAdapterRef(adapters) + return map[string]any{"model": defaultModel, "thinkingModel": defaultModel}, nil +} + func buildBootstrapStatsigPayload(reqCtx *RequestContext) (map[string]any, error) { generatedAtMs := uint64(time.Now().UnixMilli()) authID := resolveBootstrapStatsigAuthID(reqCtx) @@ -823,6 +864,16 @@ func collectModelAdapterRefs(adapters []legacyruntime.ModelAdapterConfig) []stri return output } +// firstModelAdapterRef возвращает канал первого адаптера или пустую строку, +// если ни один адаптер не сконфигурирован. +func firstModelAdapterRef(adapters []legacyruntime.ModelAdapterConfig) string { + refs := collectModelAdapterRefs(adapters) + if len(refs) == 0 { + return "" + } + return refs[0] +} + func resolveBootstrapStatsigAuthID(reqCtx *RequestContext) string { if reqCtx != nil { if authID := authIDFromBearer(reqCtx.Headers.Get("authorization")); authID != "" { From 5df3e8f5120fc08842fa506be1f8c894e6d72825 Mon Sep 17 00:00:00 2001 From: leookun Date: Mon, 3 Aug 2026 22:56:17 +0800 Subject: [PATCH 2/2] feat(cursor): update model handling to use agentv1 responses and enhance CLI model details - Refactored response handling in newProtoMessage to utilize agentv1 for GetUsableModelsResponse and GetDefaultModelForCliResponse. - Added new tests for buildCLIModelDetails and encodeCLIModels to ensure correct model details and encoding. - Updated buildUsableModelsPayload and buildDefaultModelForCliPayload to leverage buildCLIModelDetails for improved model data structure. --- internal/backend/server/upstream/client.go | 5 +- internal/backend/server/upstream/mocks.go | 39 ++++++++++++---- .../backend/server/upstream/mocks_test.go | 46 +++++++++++++++++++ 3 files changed, 78 insertions(+), 12 deletions(-) diff --git a/internal/backend/server/upstream/client.go b/internal/backend/server/upstream/client.go index 47bc314..dfedcd8 100644 --- a/internal/backend/server/upstream/client.go +++ b/internal/backend/server/upstream/client.go @@ -14,6 +14,7 @@ import ( "strings" "time" + "cursor/gen/agentv1" "cursor/gen/aiserverv1" "cursor/internal/logger" "cursor/internal/netproxy" @@ -433,9 +434,9 @@ func newProtoMessage(typeName string) (proto.Message, error) { case "aiserver.v1.ListMarketplacesResponse": return &aiserverv1.ListMarketplacesResponse{}, nil case "aiserver.v1.GetUsableModelsResponse": - return &aiserverv1.GetUsableModelsResponse{}, nil + return &agentv1.GetUsableModelsResponse{}, nil case "aiserver.v1.GetDefaultModelForCliResponse": - return &aiserverv1.GetDefaultModelForCliResponse{}, nil + return &agentv1.GetDefaultModelForCliResponse{}, nil case "aiserver.v1.GetDefaultModelResponse": return &aiserverv1.GetDefaultModelResponse{}, nil case "aiserver.v1.GetGlobalCommandsResponse": diff --git a/internal/backend/server/upstream/mocks.go b/internal/backend/server/upstream/mocks.go index c299104..cc61898 100644 --- a/internal/backend/server/upstream/mocks.go +++ b/internal/backend/server/upstream/mocks.go @@ -431,8 +431,8 @@ func buildServerTimePayload(*RequestContext) (map[string]any, error) { func buildServerConfigPayload(*RequestContext) (map[string]any, error) { return map[string]any{ - "configVersion": "local_cli_sandbox_defaults_disabled_v2", - // "http2Config": "HTTP2_CONFIG_FORCE_ALL_DISABLED", + "configVersion": "local_cli_sandbox_defaults_disabled_v2", + "http2Config": "HTTP2_CONFIG_FORCE_ALL_DISABLED", "cliSandboxDefaultEnabled": true, "indexingConfig": map[string]any{ "defaultUserPathEncryptionKey": localPathEncryptionKey, @@ -451,6 +451,7 @@ func buildAvailableModelsPayload(reqCtx *RequestContext) (map[string]any, error) if len(modelRefs) > 0 { defaultModel = modelRefs[0] } + modelEntries := buildAvailableModelEntries(adapters) return map[string]any{ "backgroundComposerModelConfig": map[string]any{ "bestOfNDefaultModels": append([]string(nil), modelRefs...), @@ -470,7 +471,7 @@ func buildAvailableModelsPayload(reqCtx *RequestContext) (map[string]any, error) "defaultModel": defaultModel, }, "disableUnusedModelsAfterNHours": availableModelsDisableUnusedHours, - "models": buildAvailableModelEntries(adapters), + "models": modelEntries, "planExecutionModelConfig": map[string]any{ "defaultModel": defaultModel, "fallbackModels": append([]string(nil), modelRefs...), @@ -502,12 +503,7 @@ func buildUsableModelsPayload(reqCtx *RequestContext) (map[string]any, error) { if err != nil { return nil, err } - refs := collectModelAdapterRefs(adapters) - models := make([]map[string]any, 0, len(refs)) - for _, ref := range refs { - models = append(models, map[string]any{"modelName": ref}) - } - return map[string]any{"models": models}, nil + return map[string]any{"models": buildCLIModelDetails(adapters)}, nil } func buildDefaultModelForCliPayload(reqCtx *RequestContext) (map[string]any, error) { @@ -515,7 +511,11 @@ func buildDefaultModelForCliPayload(reqCtx *RequestContext) (map[string]any, err if err != nil { return nil, err } - return map[string]any{"model": map[string]any{"modelName": firstModelAdapterRef(adapters)}}, nil + models := buildCLIModelDetails(adapters) + if len(models) == 0 { + return map[string]any{"model": map[string]any{}}, nil + } + return map[string]any{"model": models[0]}, nil } func buildDefaultModelPayload(reqCtx *RequestContext) (map[string]any, error) { @@ -720,6 +720,25 @@ func buildAvailableModelEntries(adapters []legacyruntime.ModelAdapterConfig) []m return output } +func buildCLIModelDetails(adapters []legacyruntime.ModelAdapterConfig) []map[string]any { + models := make([]map[string]any, 0, len(adapters)) + for _, adapter := range adapters { + channelID := strings.TrimSpace(adapter.ID) + if channelID == "" { + continue + } + models = append(models, map[string]any{ + "modelId": channelID, + "displayModelId": channelID, + "apiKeyCredentials": map[string]any{ + "apiKey": strings.TrimSpace(adapter.APIKey), + "baseUrl": strings.TrimSpace(adapter.BaseURL), + }, + }) + } + return models +} + func buildThinkingEffortParameterDefinitions(adapterType string) []map[string]any { values := thinkingEffortValuesForAdapter(adapterType) options := make([]map[string]any, 0, len(values)) diff --git a/internal/backend/server/upstream/mocks_test.go b/internal/backend/server/upstream/mocks_test.go index 5c878b9..48c4fa6 100644 --- a/internal/backend/server/upstream/mocks_test.go +++ b/internal/backend/server/upstream/mocks_test.go @@ -2,9 +2,55 @@ package upstream import ( "encoding/json" + "reflect" "testing" + + "cursor/gen/agentv1" + legacyruntime "cursor/internal/runtime" + + "google.golang.org/protobuf/proto" ) +func TestBuildCLIModelDetailsPreservesChannelCredentials(t *testing.T) { + adapters := []legacyruntime.ModelAdapterConfig{ + {ID: " channel-a ", ModelID: "model-a", APIKey: "provider-secret-a", BaseURL: "https://provider-a.example/v1"}, + {ID: "channel-b", ModelID: "model-a"}, + {ID: "", ModelID: "model-c"}, + } + + got := buildCLIModelDetails(adapters) + want := []map[string]any{ + {"modelId": "channel-a", "displayModelId": "channel-a", "apiKeyCredentials": map[string]any{"apiKey": "provider-secret-a", "baseUrl": "https://provider-a.example/v1"}}, + {"modelId": "channel-b", "displayModelId": "channel-b", "apiKeyCredentials": map[string]any{"apiKey": "", "baseUrl": ""}}, + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("build CLI model details: got %v, want %v", got, want) + } +} + +func TestEncodeCLIModelsUsesAgentModelDetailsWireFormat(t *testing.T) { + payload := map[string]any{"models": buildCLIModelDetails([]legacyruntime.ModelAdapterConfig{{ID: "channel-a", APIKey: "provider-secret", BaseURL: "https://provider.example/v1"}})} + encoded, err := encodeMockProto("aiserver.v1.GetUsableModelsResponse", payload) + if err != nil { + t.Fatalf("encode CLI models: %v", err) + } + + response := &agentv1.GetUsableModelsResponse{} + if err := proto.Unmarshal(encoded, response); err != nil { + t.Fatalf("decode CLI models with agent proto: %v", err) + } + if len(response.Models) != 1 { + t.Fatalf("decoded model count: got %d, want 1", len(response.Models)) + } + model := response.Models[0] + if model.GetModelId() != "channel-a" || model.GetDisplayModelId() != "channel-a" { + t.Fatalf("decoded channel IDs: model=%q display=%q", model.GetModelId(), model.GetDisplayModelId()) + } + if credentials := model.GetApiKeyCredentials(); credentials == nil || credentials.GetApiKey() != "provider-secret" || credentials.GetBaseUrl() != "https://provider.example/v1" { + t.Fatalf("decoded relay credentials: %#v", credentials) + } +} + func TestBuildBootstrapStatsigConfigJSONDisablesAlwaysLocalDecompositionGate(t *testing.T) { payload, err := buildBootstrapStatsigConfigJSON(12345, "test-auth-id") if err != nil {